Redesign products list and form to match catalog mockups.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "kind" TEXT NOT NULL DEFAULT 'product';
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "category" TEXT;
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "unit" TEXT DEFAULT 'عدد';
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "stockQty" INTEGER;
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "brand" TEXT;
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "model" TEXT;
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "country" TEXT;
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Product_organizationId_category_idx" ON "Product"("organizationId", "category");
|
||||
@@ -504,9 +504,18 @@ model Product {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
/// product | service
|
||||
kind String @default("product")
|
||||
name String
|
||||
sku String?
|
||||
description String?
|
||||
category String?
|
||||
unit String? @default("عدد")
|
||||
stockQty Int?
|
||||
brand String?
|
||||
model String?
|
||||
country String?
|
||||
tags String[] @default([])
|
||||
unitPrice Decimal? @db.Decimal(18, 2)
|
||||
currency String @default("IRR")
|
||||
isActive Boolean @default(true)
|
||||
@@ -518,6 +527,7 @@ model Product {
|
||||
|
||||
@@index([organizationId, name])
|
||||
@@index([organizationId, isActive])
|
||||
@@index([organizationId, category])
|
||||
}
|
||||
|
||||
model DealLineItem {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -11,6 +14,10 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsOptional()
|
||||
@IsIn(['product', 'service'])
|
||||
kind?: 'product' | 'service';
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
@@ -26,6 +33,43 @@ export class CreateProductDto {
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
unit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stockQty?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
brand?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
model?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@@ -44,6 +88,10 @@ export class CreateProductDto {
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsIn(['product', 'service'])
|
||||
kind?: 'product' | 'service';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@@ -60,6 +108,43 @@ export class UpdateProductDto {
|
||||
@MaxLength(1000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
category?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
unit?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
stockQty?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
brand?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
model?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
country?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@@ -86,4 +171,12 @@ export class ListProductsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['product', 'service'])
|
||||
kind?: 'product' | 'service';
|
||||
}
|
||||
|
||||
@@ -29,9 +29,19 @@ export class ProductsController {
|
||||
return this.productsService.list(user.organizationId, {
|
||||
activeOnly: query.activeOnly,
|
||||
q: query.q,
|
||||
category: query.category,
|
||||
kind: query.kind,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getOne(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.productsService.get(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
|
||||
@@ -13,9 +13,17 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type ProductRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
sku: string | null;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
unit: string | null;
|
||||
stockQty: number | null;
|
||||
brand: string | null;
|
||||
model: string | null;
|
||||
country: string | null;
|
||||
tags: string[];
|
||||
unitPrice: Prisma.Decimal | null;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
@@ -29,17 +37,38 @@ export class ProductsService {
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts?: { activeOnly?: boolean; q?: string },
|
||||
opts?: {
|
||||
activeOnly?: boolean;
|
||||
q?: string;
|
||||
category?: string;
|
||||
kind?: string;
|
||||
},
|
||||
): Promise<ProductDto[]> {
|
||||
const rows = await this.prisma.product.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(opts?.activeOnly ? { isActive: true } : {}),
|
||||
...(opts?.category?.trim()
|
||||
? { category: opts.category.trim() }
|
||||
: {}),
|
||||
...(opts?.kind ? { kind: opts.kind } : {}),
|
||||
...(opts?.q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ sku: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{
|
||||
description: {
|
||||
contains: opts.q.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
category: {
|
||||
contains: opts.q.trim(),
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
@@ -49,6 +78,14 @@ export class ProductsService {
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async get(organizationId: string, id: string): Promise<ProductDto> {
|
||||
const row = await this.prisma.product.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('محصول پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateProductRequest,
|
||||
@@ -59,9 +96,17 @@ export class ProductsService {
|
||||
const row = await this.prisma.product.create({
|
||||
data: {
|
||||
organizationId,
|
||||
kind: dto.kind === 'service' ? 'service' : 'product',
|
||||
name,
|
||||
sku: emptyToNull(dto.sku),
|
||||
description: emptyToNull(dto.description),
|
||||
category: emptyToNull(dto.category),
|
||||
unit: emptyToNull(dto.unit) ?? 'عدد',
|
||||
stockQty: normalizeStock(dto.stockQty),
|
||||
brand: emptyToNull(dto.brand),
|
||||
model: emptyToNull(dto.model),
|
||||
country: emptyToNull(dto.country),
|
||||
tags: normalizeTags(dto.tags),
|
||||
unitPrice:
|
||||
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
|
||||
? null
|
||||
@@ -80,6 +125,9 @@ export class ProductsService {
|
||||
): Promise<ProductDto> {
|
||||
await this.ensureExists(organizationId, id);
|
||||
const data: Prisma.ProductUpdateInput = {};
|
||||
if (dto.kind !== undefined) {
|
||||
data.kind = dto.kind === 'service' ? 'service' : 'product';
|
||||
}
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('نام محصول الزامی است');
|
||||
@@ -87,6 +135,13 @@ export class ProductsService {
|
||||
}
|
||||
if ('sku' in dto) data.sku = emptyToNull(dto.sku);
|
||||
if ('description' in dto) data.description = emptyToNull(dto.description);
|
||||
if ('category' in dto) data.category = emptyToNull(dto.category);
|
||||
if ('unit' in dto) data.unit = emptyToNull(dto.unit) ?? 'عدد';
|
||||
if ('stockQty' in dto) data.stockQty = normalizeStock(dto.stockQty);
|
||||
if ('brand' in dto) data.brand = emptyToNull(dto.brand);
|
||||
if ('model' in dto) data.model = emptyToNull(dto.model);
|
||||
if ('country' in dto) data.country = emptyToNull(dto.country);
|
||||
if (dto.tags !== undefined) data.tags = normalizeTags(dto.tags);
|
||||
if ('unitPrice' in dto) {
|
||||
data.unitPrice =
|
||||
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
|
||||
@@ -122,9 +177,17 @@ export class ProductsService {
|
||||
private toDto(row: ProductRow): ProductDto {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind === 'service' ? 'service' : 'product',
|
||||
name: row.name,
|
||||
sku: row.sku,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
unit: row.unit,
|
||||
stockQty: row.stockQty,
|
||||
brand: row.brand,
|
||||
model: row.model,
|
||||
country: row.country,
|
||||
tags: row.tags ?? [],
|
||||
unitPrice: row.unitPrice == null ? null : Number(row.unitPrice),
|
||||
currency: row.currency,
|
||||
isActive: row.isActive,
|
||||
@@ -139,3 +202,20 @@ function emptyToNull(value?: string | null): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeStock(value?: number | null): number | null {
|
||||
if (value == null) return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function normalizeTags(tags?: string[] | null): string[] {
|
||||
if (!tags?.length) return [];
|
||||
const out: string[] = [];
|
||||
for (const t of tags) {
|
||||
const s = String(t ?? '').trim();
|
||||
if (s && !out.includes(s)) out.push(s.slice(0, 40));
|
||||
}
|
||||
return out.slice(0, 20);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user