From 55f360c13ff6e907b60af0b7c039398a42645f3b Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 5 Sep 2026 16:26:09 +0330 Subject: [PATCH] Redesign products list and form to match catalog mockups. Co-authored-by: Cursor --- .../migration.sql | 12 + apps/api/prisma/schema.prisma | 10 + apps/api/src/products/dto.ts | 93 ++ apps/api/src/products/products.controller.ts | 10 + apps/api/src/products/products.service.ts | 82 +- apps/web/src/router/index.ts | 12 + apps/web/src/views/ProductFormView.vue | 673 +++++++++++++ apps/web/src/views/ProductsSettingsView.vue | 946 +++++++++++++----- packages/shared/src/index.ts | 24 + 9 files changed, 1626 insertions(+), 236 deletions(-) create mode 100644 apps/api/prisma/migrations/20260905120000_product_catalog_fields/migration.sql create mode 100644 apps/web/src/views/ProductFormView.vue diff --git a/apps/api/prisma/migrations/20260905120000_product_catalog_fields/migration.sql b/apps/api/prisma/migrations/20260905120000_product_catalog_fields/migration.sql new file mode 100644 index 0000000..9746cc3 --- /dev/null +++ b/apps/api/prisma/migrations/20260905120000_product_catalog_fields/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5b98711..cd04758 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 { diff --git a/apps/api/src/products/dto.ts b/apps/api/src/products/dto.ts index b6a509e..a5ad790 100644 --- a/apps/api/src/products/dto.ts +++ b/apps/api/src/products/dto.ts @@ -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'; } diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts index 8605662..24a91d4 100644 --- a/apps/api/src/products/products.controller.ts +++ b/apps/api/src/products/products.controller.ts @@ -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( diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts index bac6ff7..d93e4f4 100644 --- a/apps/api/src/products/products.service.ts +++ b/apps/api/src/products/products.service.ts @@ -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 { 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 { + 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 { 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); +} diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 26f09ae..7ab5265 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -198,6 +198,18 @@ export const router = createRouter({ component: () => import('@/views/ProductsSettingsView.vue'), meta: { roles: ['owner', 'admin'] as UserRole[] }, }, + { + path: 'settings/products/new', + name: 'product-new', + component: () => import('@/views/ProductFormView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, + { + path: 'settings/products/:id/edit', + name: 'product-edit', + component: () => import('@/views/ProductFormView.vue'), + meta: { roles: ['owner', 'admin'] as UserRole[] }, + }, { path: 'settings/currencies', name: 'currencies', diff --git a/apps/web/src/views/ProductFormView.vue b/apps/web/src/views/ProductFormView.vue new file mode 100644 index 0000000..33525cf --- /dev/null +++ b/apps/web/src/views/ProductFormView.vue @@ -0,0 +1,673 @@ + + +