Redesign products list and form to match catalog mockups.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-05 16:26:09 +03:30
co-authored by Cursor
parent 028362da44
commit 55f360c13f
9 changed files with 1626 additions and 236 deletions
+93
View File
@@ -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(
+81 -1
View File
@@ -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);
}