diff --git a/apps/api/src/products/dto/create-brand.dto.ts b/apps/api/src/products/dto/create-brand.dto.ts new file mode 100644 index 0000000..4085252 --- /dev/null +++ b/apps/api/src/products/dto/create-brand.dto.ts @@ -0,0 +1,9 @@ +import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator"; + +export class CreateBrandDto { + @IsString() + @MinLength(1) + name!: string; + + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/products/dto/create-product-group.dto.ts b/apps/api/src/products/dto/create-product-group.dto.ts new file mode 100644 index 0000000..2a07b40 --- /dev/null +++ b/apps/api/src/products/dto/create-product-group.dto.ts @@ -0,0 +1,9 @@ +import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator"; + +export class CreateProductGroupDto { + @IsString() + @MinLength(1) + name!: string; + + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/products/dto/create-product-subgroup.dto.ts b/apps/api/src/products/dto/create-product-subgroup.dto.ts new file mode 100644 index 0000000..5c4b34b --- /dev/null +++ b/apps/api/src/products/dto/create-product-subgroup.dto.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsOptional, IsUUID, MinLength, IsString } from "class-validator"; + +export class CreateProductSubgroupDto { + @IsString() + @MinLength(1) + name!: string; + + @IsUUID() + groupId!: string; + + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/products/dto/create-product-type.dto.ts b/apps/api/src/products/dto/create-product-type.dto.ts new file mode 100644 index 0000000..aa94a79 --- /dev/null +++ b/apps/api/src/products/dto/create-product-type.dto.ts @@ -0,0 +1,9 @@ +import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator"; + +export class CreateProductTypeDto { + @IsString() + @MinLength(1) + name!: string; + + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/products/dto/create-product.dto.ts b/apps/api/src/products/dto/create-product.dto.ts index a450744..89d8aa9 100644 --- a/apps/api/src/products/dto/create-product.dto.ts +++ b/apps/api/src/products/dto/create-product.dto.ts @@ -1,4 +1,4 @@ -import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, Min, MinLength, ValidateNested } from "class-validator"; +import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min, MinLength, ValidateNested } from "class-validator"; import { Type } from "class-transformer"; import { SERVICE_TYPES, type ServiceType } from "@eden/domain-shared"; import { TariffRatesDto } from "./tariff-rate.dto.js"; @@ -10,14 +10,19 @@ export class CreateProductDto { @IsOptional() @IsString() description?: string; - @IsString() - @MinLength(1) - productType!: string; + @IsUUID() + productTypeId!: string; - @IsOptional() @IsString() productGroup?: string; - @IsOptional() @IsString() productSubgroup?: string; - @IsOptional() @IsString() brand?: string; - @IsOptional() @IsString() unit?: string; + @IsOptional() @IsUUID() productGroupId?: string; + // `null` is only meaningful on update (PATCH) — an explicit "remove the + // subgroup" instruction, distinct from omitting the field ("leave + // unchanged"). On create it's equivalent to omitting it. See + // ProductsService.assertSubgroupConsistent. + @IsOptional() @IsUUID() productSubgroupId?: string | null; + @IsOptional() @IsUUID() brandId?: string; + + @IsUUID() + unitId!: string; @IsOptional() @IsBoolean() active?: boolean; @IsOptional() @IsBoolean() discontinued?: boolean; diff --git a/apps/api/src/products/dto/create-unit.dto.ts b/apps/api/src/products/dto/create-unit.dto.ts new file mode 100644 index 0000000..b7cfec7 --- /dev/null +++ b/apps/api/src/products/dto/create-unit.dto.ts @@ -0,0 +1,9 @@ +import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator"; + +export class CreateUnitDto { + @IsString() + @MinLength(1) + name!: string; + + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/products/dto/update-brand.dto.ts b/apps/api/src/products/dto/update-brand.dto.ts new file mode 100644 index 0000000..d19e3e7 --- /dev/null +++ b/apps/api/src/products/dto/update-brand.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateBrandDto } from "./create-brand.dto.js"; + +export class UpdateBrandDto extends PartialType(CreateBrandDto) {} diff --git a/apps/api/src/products/dto/update-product-group.dto.ts b/apps/api/src/products/dto/update-product-group.dto.ts new file mode 100644 index 0000000..7054c70 --- /dev/null +++ b/apps/api/src/products/dto/update-product-group.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateProductGroupDto } from "./create-product-group.dto.js"; + +export class UpdateProductGroupDto extends PartialType(CreateProductGroupDto) {} diff --git a/apps/api/src/products/dto/update-product-subgroup.dto.ts b/apps/api/src/products/dto/update-product-subgroup.dto.ts new file mode 100644 index 0000000..679ffa0 --- /dev/null +++ b/apps/api/src/products/dto/update-product-subgroup.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator"; + +/** groupId is deliberately not patchable here — moving a subgroup to a different group is not part of this MVP; create a new subgroup under the target group instead. */ +export class UpdateProductSubgroupDto { + @IsOptional() @IsString() @MinLength(1) name?: string; + @IsOptional() @IsBoolean() active?: boolean; +} diff --git a/apps/api/src/products/dto/update-product-type.dto.ts b/apps/api/src/products/dto/update-product-type.dto.ts new file mode 100644 index 0000000..fd91413 --- /dev/null +++ b/apps/api/src/products/dto/update-product-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateProductTypeDto } from "./create-product-type.dto.js"; + +export class UpdateProductTypeDto extends PartialType(CreateProductTypeDto) {} diff --git a/apps/api/src/products/dto/update-unit.dto.ts b/apps/api/src/products/dto/update-unit.dto.ts new file mode 100644 index 0000000..04738f3 --- /dev/null +++ b/apps/api/src/products/dto/update-unit.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateUnitDto } from "./create-unit.dto.js"; + +export class UpdateUnitDto extends PartialType(CreateUnitDto) {} diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts index 3f27bd5..3f0b35c 100644 --- a/apps/api/src/products/products.controller.ts +++ b/apps/api/src/products/products.controller.ts @@ -3,6 +3,16 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { ProductsService } from "./products.service.js"; import { CreateProductDto } from "./dto/create-product.dto.js"; import { UpdateProductDto } from "./dto/update-product.dto.js"; +import { CreateProductTypeDto } from "./dto/create-product-type.dto.js"; +import { UpdateProductTypeDto } from "./dto/update-product-type.dto.js"; +import { CreateProductGroupDto } from "./dto/create-product-group.dto.js"; +import { UpdateProductGroupDto } from "./dto/update-product-group.dto.js"; +import { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js"; +import { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js"; +import { CreateBrandDto } from "./dto/create-brand.dto.js"; +import { UpdateBrandDto } from "./dto/update-brand.dto.js"; +import { CreateUnitDto } from "./dto/create-unit.dto.js"; +import { UpdateUnitDto } from "./dto/update-unit.dto.js"; import { JwtAuthGuard } from "../auth/jwt-auth.guard.js"; import { PermissionGuard } from "../auth/permission.guard.js"; import { RequirePermission } from "../auth/decorators/require-permission.decorator.js"; @@ -16,6 +26,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; * for companies. Write requires products:edit. Backend always * recalculates/validates: money fields are format-checked (non-negative) * in the DTO but no pricing logic runs here — that's Quotes, a later phase. + * + * Tipo/Grupo/Subgrupo/Marca/Unidade sub-catalogs (product_types/ + * product_groups/product_subgroups/brands/units) share this controller, + * same style as FiscalController grouping fiscal_ncm/fiscal_cfop/etc. — no + * physical delete for any of them in this MVP, `active=false` is the + * "apagar". */ @ApiTags("products") @ApiBearerAuth() @@ -29,6 +45,126 @@ export class ProductsController { return this.products.list(includeInactive === "true"); } + // -- product_types -------------------------------------------------------- + + @Get("types") + listTypes(@Query("includeInactive") includeInactive?: string) { + return this.products.listProductTypes(includeInactive === "true"); + } + + @Post("types") + @RequirePermission("products", "edit") + createType(@Body() dto: CreateProductTypeDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.products.createProductType(dto, user); + } + + @Patch("types/:id") + @RequirePermission("products", "edit") + updateType( + @Param("id") id: string, + @Body() dto: UpdateProductTypeDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.products.updateProductType(id, dto, user); + } + + // -- product_groups --------------------------------------------------------- + + @Get("groups") + listGroups(@Query("includeInactive") includeInactive?: string) { + return this.products.listProductGroups(includeInactive === "true"); + } + + @Post("groups") + @RequirePermission("products", "edit") + createGroup(@Body() dto: CreateProductGroupDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.products.createProductGroup(dto, user); + } + + @Patch("groups/:id") + @RequirePermission("products", "edit") + updateGroup( + @Param("id") id: string, + @Body() dto: UpdateProductGroupDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.products.updateProductGroup(id, dto, user); + } + + // -- product_subgroups -------------------------------------------------------- + + @Get("subgroups") + listSubgroups( + @Query("groupId") groupId?: string, + @Query("includeInactive") includeInactive?: string, + ) { + return this.products.listProductSubgroups(groupId, includeInactive === "true"); + } + + @Post("subgroups") + @RequirePermission("products", "edit") + createSubgroup(@Body() dto: CreateProductSubgroupDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.products.createProductSubgroup(dto, user); + } + + @Patch("subgroups/:id") + @RequirePermission("products", "edit") + updateSubgroup( + @Param("id") id: string, + @Body() dto: UpdateProductSubgroupDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.products.updateProductSubgroup(id, dto, user); + } + + // -- brands ----------------------------------------------------------------- + + @Get("brands") + listBrands(@Query("includeInactive") includeInactive?: string) { + return this.products.listBrands(includeInactive === "true"); + } + + @Post("brands") + @RequirePermission("products", "edit") + createBrand(@Body() dto: CreateBrandDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.products.createBrand(dto, user); + } + + @Patch("brands/:id") + @RequirePermission("products", "edit") + updateBrand( + @Param("id") id: string, + @Body() dto: UpdateBrandDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.products.updateBrand(id, dto, user); + } + + // -- units -------------------------------------------------------------------- + + @Get("units") + listUnits(@Query("includeInactive") includeInactive?: string) { + return this.products.listUnits(includeInactive === "true"); + } + + @Post("units") + @RequirePermission("products", "edit") + createUnit(@Body() dto: CreateUnitDto, @CurrentUser() user: EdenAuthenticatedUser) { + return this.products.createUnit(dto, user); + } + + @Patch("units/:id") + @RequirePermission("products", "edit") + updateUnit( + @Param("id") id: string, + @Body() dto: UpdateUnitDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.products.updateUnit(id, dto, user); + } + + // -- products (:id must come after every static sub-route above) -------------- + @Get(":id") get(@Param("id") id: string) { return this.products.get(id); diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts index e94589c..8a4cd24 100644 --- a/apps/api/src/products/products.service.ts +++ b/apps/api/src/products/products.service.ts @@ -1,8 +1,29 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; -import { auditRepo, productsRepo, type Product } from "@eden/database"; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { + auditRepo, + productsRepo, + productCatalogsRepo, + type Product, + type ProductType, + type ProductGroup, + type ProductSubgroup, + type Brand, + type Unit, +} from "@eden/database"; import { emptyTariffRates, type TariffRates } from "@eden/domain-shared"; import type { CreateProductDto } from "./dto/create-product.dto.js"; import type { UpdateProductDto } from "./dto/update-product.dto.js"; +import type { CreateProductTypeDto } from "./dto/create-product-type.dto.js"; +import type { UpdateProductTypeDto } from "./dto/update-product-type.dto.js"; +import type { CreateProductGroupDto } from "./dto/create-product-group.dto.js"; +import type { UpdateProductGroupDto } from "./dto/update-product-group.dto.js"; +import type { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js"; +import type { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js"; +import type { CreateBrandDto } from "./dto/create-brand.dto.js"; +import type { UpdateBrandDto } from "./dto/update-brand.dto.js"; +import type { CreateUnitDto } from "./dto/create-unit.dto.js"; +import type { UpdateUnitDto } from "./dto/update-unit.dto.js"; +import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; /** DTO fields are individually optional (partial override); persisted tariff_rates always carries all 6 keys — mirrors the legacy's normalizeTariffRates(), never a partial object at rest. */ function normalizeTariffRates(partial: Partial | undefined): TariffRates | undefined { @@ -10,6 +31,10 @@ function normalizeTariffRates(partial: Partial | undefined): Tariff return { ...emptyTariffRates(), ...partial }; } +function isUniqueViolation(err: unknown): boolean { + return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505"; +} + @Injectable() export class ProductsService { list(includeInactive: boolean): Promise { @@ -23,14 +48,17 @@ export class ProductsService { } async create(dto: CreateProductDto, actorId: string): Promise { + await this.assertCatalogReferencesValid(dto); + await this.assertSubgroupConsistent(dto, null); + const product = await productsRepo.createProduct({ name: dto.name, description: dto.description, - productType: dto.productType, - productGroup: dto.productGroup, - productSubgroup: dto.productSubgroup, - brand: dto.brand, - unit: dto.unit, + productTypeId: dto.productTypeId, + productGroupId: dto.productGroupId, + productSubgroupId: dto.productSubgroupId, + brandId: dto.brandId, + unitId: dto.unitId, active: dto.active, discontinued: dto.discontinued, tracksStock: dto.tracksStock, @@ -63,16 +91,18 @@ export class ProductsService { } async update(id: string, dto: UpdateProductDto, actorId: string): Promise { - await this.get(id); // 404 if missing + const current = await this.get(id); // 404 if missing + await this.assertCatalogReferencesValid(dto); + await this.assertSubgroupConsistent(dto, current); - const product = await productsRepo.updateProduct(id, { + let product = await productsRepo.updateProduct(id, { name: dto.name, description: dto.description, - productType: dto.productType, - productGroup: dto.productGroup, - productSubgroup: dto.productSubgroup, - brand: dto.brand, - unit: dto.unit, + productTypeId: dto.productTypeId, + productGroupId: dto.productGroupId, + productSubgroupId: dto.productSubgroupId, + brandId: dto.brandId, + unitId: dto.unitId, active: dto.active, discontinued: dto.discontinued, tracksStock: dto.tracksStock, @@ -93,6 +123,15 @@ export class ProductsService { }); if (!product) throw new NotFoundException("Produto não encontrado"); + // `productSubgroupId: null` is an explicit clear — the COALESCE-based + // update above can't express "set to NULL" vs. "leave unchanged" + // (both collapse to the same SQL NULL parameter), so it's a deliberate + // no-op there and corrected here with a dedicated statement. + if (dto.productSubgroupId === null) { + await productsRepo.clearProductSubgroup(id); + product = (await productsRepo.getProductById(id)) ?? product; + } + await auditRepo.appendAuditEvent({ actorType: "user", actorId, @@ -104,4 +143,358 @@ export class ProductsService { return product; } + + /** + * Validates the simple (existence-only) catalog references on + * create/update: type/unit/group/brand must exist. Subgroup consistency + * is handled separately by `assertSubgroupConsistent` — it needs the + * product's current state on update, which this method doesn't have. + */ + private async assertCatalogReferencesValid( + dto: Partial>, + ): Promise { + if (dto.productTypeId !== undefined) { + const type = await productCatalogsRepo.getProductTypeById(dto.productTypeId); + if (!type) throw new BadRequestException("Tipo de produto não encontrado"); + } + if (dto.unitId !== undefined) { + const unit = await productCatalogsRepo.getUnitById(dto.unitId); + if (!unit) throw new BadRequestException("Unidade não encontrada"); + } + if (dto.brandId !== undefined) { + const brand = await productCatalogsRepo.getBrandById(dto.brandId); + if (!brand) throw new BadRequestException("Marca não encontrada"); + } + if (dto.productGroupId !== undefined) { + const group = await productCatalogsRepo.getProductGroupById(dto.productGroupId); + if (!group) throw new BadRequestException("Grupo de produto não encontrado"); + } + } + + /** + * `productSubgroupId === null` is an explicit "remove the subgroup" + * instruction — always valid, never looked up. Anything else is + * resolved against the EFFECTIVE resulting state (the DTO's value where + * provided, falling back to the current product's value otherwise) so a + * PATCH that only changes `productGroupId` can't silently leave behind a + * subgroup that no longer belongs to the new group — it's rejected with + * a clear message instead of a corrupted invariant. + */ + private async assertSubgroupConsistent( + dto: Pick, + current: Pick | null, + ): Promise { + if (dto.productSubgroupId === null) return; + + const effectiveGroupId = dto.productGroupId !== undefined ? dto.productGroupId : (current?.product_group_id ?? undefined); + const effectiveSubgroupId = dto.productSubgroupId !== undefined ? dto.productSubgroupId : (current?.product_subgroup_id ?? undefined); + if (!effectiveSubgroupId) return; + + const subgroup = await productCatalogsRepo.getProductSubgroupById(effectiveSubgroupId); + if (!subgroup) throw new BadRequestException("Subgrupo de produto não encontrado"); + if (!effectiveGroupId) { + throw new BadRequestException("Grupo de produto é obrigatório quando um subgrupo é informado"); + } + if (subgroup.group_id !== effectiveGroupId) { + throw new BadRequestException( + dto.productGroupId !== undefined && dto.productSubgroupId === undefined + ? "O novo grupo é incompatível com o subgrupo atual do produto — informe também um subgrupo válido para o novo grupo, ou envie productSubgroupId: null para removê-lo." + : "Subgrupo não pertence ao grupo informado", + ); + } + } + + // --------------------------------------------------------------------- + // product_types + // --------------------------------------------------------------------- + + listProductTypes(includeInactive: boolean): Promise { + return productCatalogsRepo.listProductTypes(includeInactive); + } + + async createProductType(dto: CreateProductTypeDto, user: EdenAuthenticatedUser): Promise { + let row: ProductType; + try { + row = await productCatalogsRepo.createProductType({ + name: dto.name, + active: dto.active, + createdBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe um tipo de produto com esse nome"); + throw err; + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "product_type.created", + resourceType: "product_type", + resourceId: row.id, + }); + return row; + } + + async updateProductType( + id: string, + dto: UpdateProductTypeDto, + user: EdenAuthenticatedUser, + ): Promise { + let row: ProductType | null; + try { + row = await productCatalogsRepo.updateProductType(id, { + name: dto.name, + active: dto.active, + updatedBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe um tipo de produto com esse nome"); + throw err; + } + if (!row) throw new NotFoundException("Tipo de produto não encontrado"); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "product_type.updated", + resourceType: "product_type", + resourceId: id, + metadata: { ...dto }, + }); + return row; + } + + // --------------------------------------------------------------------- + // product_groups + // --------------------------------------------------------------------- + + listProductGroups(includeInactive: boolean): Promise { + return productCatalogsRepo.listProductGroups(includeInactive); + } + + async createProductGroup(dto: CreateProductGroupDto, user: EdenAuthenticatedUser): Promise { + let row: ProductGroup; + try { + row = await productCatalogsRepo.createProductGroup({ + name: dto.name, + active: dto.active, + createdBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe um grupo de produto com esse nome"); + throw err; + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "product_group.created", + resourceType: "product_group", + resourceId: row.id, + }); + return row; + } + + async updateProductGroup( + id: string, + dto: UpdateProductGroupDto, + user: EdenAuthenticatedUser, + ): Promise { + let row: ProductGroup | null; + try { + row = await productCatalogsRepo.updateProductGroup(id, { + name: dto.name, + active: dto.active, + updatedBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe um grupo de produto com esse nome"); + throw err; + } + if (!row) throw new NotFoundException("Grupo de produto não encontrado"); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "product_group.updated", + resourceType: "product_group", + resourceId: id, + metadata: { ...dto }, + }); + return row; + } + + // --------------------------------------------------------------------- + // product_subgroups + // --------------------------------------------------------------------- + + listProductSubgroups(groupId: string | undefined, includeInactive: boolean): Promise { + return productCatalogsRepo.listProductSubgroups(groupId, includeInactive); + } + + async createProductSubgroup( + dto: CreateProductSubgroupDto, + user: EdenAuthenticatedUser, + ): Promise { + const group = await productCatalogsRepo.getProductGroupById(dto.groupId); + if (!group) throw new BadRequestException("Grupo de produto não encontrado"); + + let row: ProductSubgroup; + try { + row = await productCatalogsRepo.createProductSubgroup({ + groupId: dto.groupId, + name: dto.name, + active: dto.active, + createdBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) { + throw new ConflictException("Já existe um subgrupo com esse nome nesse grupo"); + } + throw err; + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "product_subgroup.created", + resourceType: "product_subgroup", + resourceId: row.id, + metadata: { groupId: dto.groupId }, + }); + return row; + } + + async updateProductSubgroup( + id: string, + dto: UpdateProductSubgroupDto, + user: EdenAuthenticatedUser, + ): Promise { + let row: ProductSubgroup | null; + try { + row = await productCatalogsRepo.updateProductSubgroup(id, { + name: dto.name, + active: dto.active, + updatedBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) { + throw new ConflictException("Já existe um subgrupo com esse nome nesse grupo"); + } + throw err; + } + if (!row) throw new NotFoundException("Subgrupo de produto não encontrado"); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "product_subgroup.updated", + resourceType: "product_subgroup", + resourceId: id, + metadata: { ...dto }, + }); + return row; + } + + // --------------------------------------------------------------------- + // brands + // --------------------------------------------------------------------- + + listBrands(includeInactive: boolean): Promise { + return productCatalogsRepo.listBrands(includeInactive); + } + + async createBrand(dto: CreateBrandDto, user: EdenAuthenticatedUser): Promise { + let row: Brand; + try { + row = await productCatalogsRepo.createBrand({ + name: dto.name, + active: dto.active, + createdBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe uma marca com esse nome"); + throw err; + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "brand.created", + resourceType: "brand", + resourceId: row.id, + }); + return row; + } + + async updateBrand(id: string, dto: UpdateBrandDto, user: EdenAuthenticatedUser): Promise { + let row: Brand | null; + try { + row = await productCatalogsRepo.updateBrand(id, { + name: dto.name, + active: dto.active, + updatedBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe uma marca com esse nome"); + throw err; + } + if (!row) throw new NotFoundException("Marca não encontrada"); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "brand.updated", + resourceType: "brand", + resourceId: id, + metadata: { ...dto }, + }); + return row; + } + + // --------------------------------------------------------------------- + // units + // --------------------------------------------------------------------- + + listUnits(includeInactive: boolean): Promise { + return productCatalogsRepo.listUnits(includeInactive); + } + + async createUnit(dto: CreateUnitDto, user: EdenAuthenticatedUser): Promise { + let row: Unit; + try { + row = await productCatalogsRepo.createUnit({ + name: dto.name, + active: dto.active, + createdBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe uma unidade com esse nome"); + throw err; + } + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "unit.created", + resourceType: "unit", + resourceId: row.id, + }); + return row; + } + + async updateUnit(id: string, dto: UpdateUnitDto, user: EdenAuthenticatedUser): Promise { + let row: Unit | null; + try { + row = await productCatalogsRepo.updateUnit(id, { + name: dto.name, + active: dto.active, + updatedBy: user.id, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ConflictException("Já existe uma unidade com esse nome"); + throw err; + } + if (!row) throw new NotFoundException("Unidade não encontrada"); + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "unit.updated", + resourceType: "unit", + resourceId: id, + metadata: { ...dto }, + }); + return row; + } } diff --git a/apps/api/test/anatel-pdf.spec.ts b/apps/api/test/anatel-pdf.spec.ts index aa525ae..8166eb5 100644 --- a/apps/api/test/anatel-pdf.spec.ts +++ b/apps/api/test/anatel-pdf.spec.ts @@ -1,6 +1,6 @@ import { INestApplication } from "@nestjs/common"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js"; +import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaName, type AuthedTokens } from "./setup.js"; /** * `renderPdf` is mocked (real call path through quotes.service.ts still @@ -22,6 +22,8 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => { let resellerId: string; let stfcProductId: string; let pabxProductId: string; + let productTypeId: string; + let unitId: string; beforeAll(async () => { app = await createTestApp(); @@ -34,12 +36,27 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => { .expect(201); resellerId = reseller.body.id; + const productType = await agent(app) + .post("/api/v1/products/types") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Tipo ANATEL") }) + .expect(201); + productTypeId = productType.body.id; + + const unit = await agent(app) + .post("/api/v1/products/units") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Unidade ANATEL") }) + .expect(201); + unitId = unit.body.id; + const stfcProduct = await agent(app) .post("/api/v1/products") .set(...bearer(admin.accessToken)) .send({ name: "qa-test- Linha STFC", - productType: "service", + productTypeId, + unitId, price0: 200, price12: 150, serviceType: "STFC", @@ -63,7 +80,7 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => { const pabxProduct = await agent(app) .post("/api/v1/products") .set(...bearer(admin.accessToken)) - .send({ name: "qa-test- Ramal PABX", productType: "service", price0: 50, serviceType: "Serviço PABX" }) + .send({ name: "qa-test- Ramal PABX", productTypeId, unitId, price0: 50, serviceType: "Serviço PABX" }) .expect(201); pabxProductId = pabxProduct.body.id; }); diff --git a/apps/api/test/contracts-edit-lock.spec.ts b/apps/api/test/contracts-edit-lock.spec.ts index c338374..c12f9c0 100644 --- a/apps/api/test/contracts-edit-lock.spec.ts +++ b/apps/api/test/contracts-edit-lock.spec.ts @@ -9,6 +9,7 @@ import { loginSuperAdmin, qaEmail, qaKey, + qaName, type AuthedTokens, } from "./setup.js"; @@ -45,10 +46,27 @@ describe("contracts — edit lock past 'draft'", () => { .expect(201); resellerId = reseller.body.id; + const productType = await agent(app) + .post("/api/v1/products/types") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Tipo Contract Lock") }) + .expect(201); + + const unit = await agent(app) + .post("/api/v1/products/units") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Unidade Contract Lock") }) + .expect(201); + const product = await agent(app) .post("/api/v1/products") .set(...bearer(admin.accessToken)) - .send({ name: "qa-test- Produto Contract Lock", productType: "service", price0: 100 }) + .send({ + name: "qa-test- Produto Contract Lock", + productTypeId: productType.body.id, + unitId: unit.body.id, + price0: 100, + }) .expect(201); productId = product.body.id; diff --git a/apps/api/test/mass-assignment.spec.ts b/apps/api/test/mass-assignment.spec.ts index 2f3f925..60191e5 100644 --- a/apps/api/test/mass-assignment.spec.ts +++ b/apps/api/test/mass-assignment.spec.ts @@ -50,7 +50,8 @@ describe("mass-assignment — unexpected fields are rejected, not silently dropp .set(...bearer(admin.accessToken)) .send({ name: "qa-test- mass assignment product", - productType: "service", + productTypeId: "11111111-1111-1111-1111-111111111111", + unitId: "11111111-1111-1111-1111-111111111111", createdBy: "11111111-1111-1111-1111-111111111111", }) .expect(400); diff --git a/apps/api/test/product-catalogs.spec.ts b/apps/api/test/product-catalogs.spec.ts new file mode 100644 index 0000000..e49bd14 --- /dev/null +++ b/apps/api/test/product-catalogs.spec.ts @@ -0,0 +1,444 @@ +import { INestApplication } from "@nestjs/common"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaName, type AuthedTokens } from "./setup.js"; + +/** + * Covers the Tipo/Grupo/Subgrupo/Marca/Unidade catalogs + * (1789694035000_product-catalogs.cjs) that replaced products' + * product_type/product_group/product_subgroup/brand/unit free-TEXT + * columns: create/list/update per catalog, case-insensitive duplicate + * name -> 409, subgroup without groupId -> 400, and the product-creation + * flow referencing these catalogs by ID including the subgroup/group + * mismatch -> 400 case (products.service.ts's assertCatalogReferencesValid). + * + * Every catalog name uses qaName() (random suffix) rather than a fixed + * "qa-test- Foo" literal: these 5 catalogs are enforced by a + * case-insensitive UNIQUE index and have no delete endpoint (active=false + * is the only "apagar"), so a hardcoded name would collide with a row left + * behind by a previous run of this suite against the same shared dev + * Postgres. + */ +describe("product catalogs — types/groups/subgroups/brands/units", () => { + let app: INestApplication; + let admin: AuthedTokens; + + beforeAll(async () => { + app = await createTestApp(); + admin = await loginSuperAdmin(app); + }); + + afterAll(async () => { + await closeTestApp(app); + }); + + it("creates, lists and updates a product type", async () => { + const name = qaName("Tipo Catalogo"); + const created = await agent(app) + .post("/api/v1/products/types") + .set(...bearer(admin.accessToken)) + .send({ name }) + .expect(201); + expect(created.body.id).toBeTypeOf("string"); + expect(created.body.active).toBe(true); + + const list = await agent(app) + .get("/api/v1/products/types") + .set(...bearer(admin.accessToken)) + .expect(200); + expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true); + + const updated = await agent(app) + .patch(`/api/v1/products/types/${created.body.id}`) + .set(...bearer(admin.accessToken)) + .send({ active: false }) + .expect(200); + expect(updated.body.active).toBe(false); + }); + + it("rejects a duplicate product type name case-insensitively with 409", async () => { + const name = qaName("Tipo Duplicado"); + await agent(app) + .post("/api/v1/products/types") + .set(...bearer(admin.accessToken)) + .send({ name }) + .expect(201); + + await agent(app) + .post("/api/v1/products/types") + .set(...bearer(admin.accessToken)) + .send({ name: name.toUpperCase() }) + .expect(409); + }); + + it("creates, lists and updates a product group", async () => { + const name = qaName("Grupo Catalogo"); + const created = await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name }) + .expect(201); + expect(created.body.id).toBeTypeOf("string"); + + const list = await agent(app) + .get("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .expect(200); + expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true); + + const renamed = `${name} Renomeado`; + const updated = await agent(app) + .patch(`/api/v1/products/groups/${created.body.id}`) + .set(...bearer(admin.accessToken)) + .send({ name: renamed }) + .expect(200); + expect(updated.body.name).toBe(renamed); + }); + + it("rejects a duplicate product group name case-insensitively with 409", async () => { + const name = qaName("Grupo Duplicado"); + await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name }) + .expect(201); + + await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name: name.toLowerCase() }) + .expect(409); + }); + + it("creates a subgroup scoped to a group, lists it filtered by groupId, and updates it", async () => { + const group = await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Grupo Para Subgrupo") }) + .expect(201); + + const subgroupName = qaName("Subgrupo A"); + const subgroup = await agent(app) + .post("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .send({ name: subgroupName, groupId: group.body.id }) + .expect(201); + expect(subgroup.body.group_id).toBe(group.body.id); + + const list = await agent(app) + .get(`/api/v1/products/subgroups?groupId=${group.body.id}`) + .set(...bearer(admin.accessToken)) + .expect(200); + expect(list.body).toHaveLength(1); + expect(list.body[0].id).toBe(subgroup.body.id); + + const renamed = `${subgroupName} Renomeado`; + const updated = await agent(app) + .patch(`/api/v1/products/subgroups/${subgroup.body.id}`) + .set(...bearer(admin.accessToken)) + .send({ name: renamed }) + .expect(200); + expect(updated.body.name).toBe(renamed); + }); + + it("allows the same subgroup name under two different groups", async () => { + const groupA = await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Grupo Repeticao A") }) + .expect(201); + const groupB = await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Grupo Repeticao B") }) + .expect(201); + + const subgroupName = qaName("Subgrupo Repetido"); + await agent(app) + .post("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .send({ name: subgroupName, groupId: groupA.body.id }) + .expect(201); + + await agent(app) + .post("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .send({ name: subgroupName, groupId: groupB.body.id }) + .expect(201); + }); + + it("rejects a duplicate subgroup name within the same group with 409", async () => { + const group = await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Grupo Subgrupo Duplicado") }) + .expect(201); + + const subgroupName = qaName("Subgrupo Dup"); + await agent(app) + .post("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .send({ name: subgroupName, groupId: group.body.id }) + .expect(201); + + await agent(app) + .post("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .send({ name: subgroupName.toUpperCase(), groupId: group.body.id }) + .expect(409); + }); + + it("rejects creating a subgroup without groupId with 400", async () => { + await agent(app) + .post("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Subgrupo Sem Grupo") }) + .expect(400); + }); + + it("creates, lists and updates a brand", async () => { + const created = await agent(app) + .post("/api/v1/products/brands") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Marca Catalogo") }) + .expect(201); + + const list = await agent(app) + .get("/api/v1/products/brands") + .set(...bearer(admin.accessToken)) + .expect(200); + expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true); + + await agent(app) + .patch(`/api/v1/products/brands/${created.body.id}`) + .set(...bearer(admin.accessToken)) + .send({ active: false }) + .expect(200); + }); + + it("rejects a duplicate brand name case-insensitively with 409", async () => { + const name = qaName("Marca Duplicada"); + await agent(app) + .post("/api/v1/products/brands") + .set(...bearer(admin.accessToken)) + .send({ name }) + .expect(201); + await agent(app) + .post("/api/v1/products/brands") + .set(...bearer(admin.accessToken)) + .send({ name: name.toUpperCase() }) + .expect(409); + }); + + it("creates, lists and updates a unit", async () => { + const created = await agent(app) + .post("/api/v1/products/units") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Unidade Catalogo") }) + .expect(201); + + const list = await agent(app) + .get("/api/v1/products/units") + .set(...bearer(admin.accessToken)) + .expect(200); + expect(list.body.some((row: { id: string }) => row.id === created.body.id)).toBe(true); + + await agent(app) + .patch(`/api/v1/products/units/${created.body.id}`) + .set(...bearer(admin.accessToken)) + .send({ active: false }) + .expect(200); + }); + + it("rejects a duplicate unit name case-insensitively with 409", async () => { + const name = qaName("Unidade Duplicada"); + await agent(app) + .post("/api/v1/products/units") + .set(...bearer(admin.accessToken)) + .send({ name }) + .expect(201); + await agent(app) + .post("/api/v1/products/units") + .set(...bearer(admin.accessToken)) + .send({ name: name.toLowerCase() }) + .expect(409); + }); + + describe("product creation using catalog IDs", () => { + let productTypeId: string; + let unitId: string; + let groupId: string; + let otherGroupId: string; + let subgroupId: string; + let brandId: string; + let productTypeName: string; + let unitName: string; + let groupName: string; + let subgroupName: string; + let brandName: string; + + beforeAll(async () => { + productTypeName = qaName("Tipo Produto Final"); + const type = await agent(app) + .post("/api/v1/products/types") + .set(...bearer(admin.accessToken)) + .send({ name: productTypeName }) + .expect(201); + productTypeId = type.body.id; + + unitName = qaName("Unidade Produto Final"); + const unit = await agent(app) + .post("/api/v1/products/units") + .set(...bearer(admin.accessToken)) + .send({ name: unitName }) + .expect(201); + unitId = unit.body.id; + + groupName = qaName("Grupo Produto Final"); + const group = await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name: groupName }) + .expect(201); + groupId = group.body.id; + + const otherGroup = await agent(app) + .post("/api/v1/products/groups") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Outro Grupo Produto Final") }) + .expect(201); + otherGroupId = otherGroup.body.id; + + subgroupName = qaName("Subgrupo Produto Final"); + const subgroup = await agent(app) + .post("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .send({ name: subgroupName, groupId }) + .expect(201); + subgroupId = subgroup.body.id; + + brandName = qaName("Marca Produto Final"); + const brand = await agent(app) + .post("/api/v1/products/brands") + .set(...bearer(admin.accessToken)) + .send({ name: brandName }) + .expect(201); + brandId = brand.body.id; + }); + + it("creates a product referencing every catalog and resolves *_name on GET", async () => { + const product = await agent(app) + .post("/api/v1/products") + .set(...bearer(admin.accessToken)) + .send({ + name: qaName("Produto Catalogos"), + productTypeId, + unitId, + productGroupId: groupId, + productSubgroupId: subgroupId, + brandId, + price0: 10, + }) + .expect(201); + + expect(product.body.product_type_id).toBe(productTypeId); + expect(product.body.unit_id).toBe(unitId); + expect(product.body.product_group_id).toBe(groupId); + expect(product.body.product_subgroup_id).toBe(subgroupId); + expect(product.body.brand_id).toBe(brandId); + + const fetched = await agent(app) + .get(`/api/v1/products/${product.body.id}`) + .set(...bearer(admin.accessToken)) + .expect(200); + expect(fetched.body.product_type_name).toBe(productTypeName); + expect(fetched.body.unit_name).toBe(unitName); + expect(fetched.body.product_group_name).toBe(groupName); + expect(fetched.body.product_subgroup_name).toBe(subgroupName); + expect(fetched.body.brand_name).toBe(brandName); + }); + + it("rejects (400) a product whose subgroup belongs to a different group than the one informed", async () => { + const res = await agent(app) + .post("/api/v1/products") + .set(...bearer(admin.accessToken)) + .send({ + name: qaName("Produto Grupo Errado"), + productTypeId, + unitId, + productGroupId: otherGroupId, + productSubgroupId: subgroupId, + }) + .expect(400); + expect(res.body.message).toContain("Subgrupo não pertence ao grupo informado"); + }); + + it("rejects (400) a product whose subgroup is informed without a groupId", async () => { + const res = await agent(app) + .post("/api/v1/products") + .set(...bearer(admin.accessToken)) + .send({ + name: qaName("Produto Sem Grupo"), + productTypeId, + unitId, + productSubgroupId: subgroupId, + }) + .expect(400); + expect(res.body.message).toContain("Grupo de produto é obrigatório"); + }); + + describe("update — subgroup consistency (COALESCE can't express 'clear', handled explicitly)", () => { + let productId: string; + + beforeAll(async () => { + const product = await agent(app) + .post("/api/v1/products") + .set(...bearer(admin.accessToken)) + .send({ + name: qaName("Produto Update Subgrupo"), + productTypeId, + unitId, + productGroupId: groupId, + productSubgroupId: subgroupId, + }) + .expect(201); + productId = product.body.id; + }); + + it("rejects (400) changing the group without resolving the now-orphaned subgroup — never silently corrupts the invariant", async () => { + const res = await agent(app) + .patch(`/api/v1/products/${productId}`) + .set(...bearer(admin.accessToken)) + .send({ productGroupId: otherGroupId }) + .expect(400); + expect(res.body.message).toMatch(/incompatível/); + + const unchanged = await agent(app).get(`/api/v1/products/${productId}`).set(...bearer(admin.accessToken)).expect(200); + expect(unchanged.body.product_group_id).toBe(groupId); + expect(unchanged.body.product_subgroup_id).toBe(subgroupId); + }); + + it("explicit productSubgroupId: null actually clears it (not a no-op)", async () => { + const res = await agent(app) + .patch(`/api/v1/products/${productId}`) + .set(...bearer(admin.accessToken)) + .send({ productSubgroupId: null }) + .expect(200); + expect(res.body.product_subgroup_id).toBeNull(); + expect(res.body.product_subgroup_name).toBeNull(); + + const fetched = await agent(app).get(`/api/v1/products/${productId}`).set(...bearer(admin.accessToken)).expect(200); + expect(fetched.body.product_subgroup_id).toBeNull(); + }); + + it("changing the group now works once the subgroup is cleared", async () => { + const res = await agent(app) + .patch(`/api/v1/products/${productId}`) + .set(...bearer(admin.accessToken)) + .send({ productGroupId: otherGroupId }) + .expect(200); + expect(res.body.product_group_id).toBe(otherGroupId); + expect(res.body.product_subgroup_id).toBeNull(); + }); + }); + }); +}); diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index 7d0d426..291aedc 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -118,3 +118,14 @@ export function qaKey(label: string): string { // apps/api/src/roles/dto/create-role.dto.ts). return `qa_${label}_${rand}`.slice(0, 30); } + +/** + * Random-suffixed display name, for fixtures backed by a case-insensitive + * UNIQUE index with no delete endpoint (e.g. product_types/product_groups/ + * brands/units) — a hardcoded name would collide with a row left behind by + * a previous run of this suite against the same shared dev database. + */ +export function qaName(label: string): string { + const rand = Math.random().toString(36).slice(2, 8); + return `${QA_PREFIX}${label} ${rand}`; +} diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index d6d53e7..b1be7f8 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -8,6 +8,10 @@ import { CompaniesListPage } from "./routes/companies/CompaniesListPage.js"; import { CompanyFormPage } from "./routes/companies/CompanyFormPage.js"; import { ProductsListPage } from "./routes/products/ProductsListPage.js"; import { ProductFormPage } from "./routes/products/ProductFormPage.js"; +import { ProductTypesListPage } from "./routes/products/catalogs/ProductTypesListPage.js"; +import { ProductGroupsListPage } from "./routes/products/catalogs/ProductGroupsListPage.js"; +import { BrandsListPage } from "./routes/products/catalogs/BrandsListPage.js"; +import { UnitsListPage } from "./routes/products/catalogs/UnitsListPage.js"; import { ResellersListPage } from "./routes/resellers/ResellersListPage.js"; import { ResellerFormPage } from "./routes/resellers/ResellerFormPage.js"; import { LeadsListPage } from "./routes/crm/LeadsListPage.js"; @@ -82,6 +86,10 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/core-web/src/lib/api/product-catalogs.ts b/apps/core-web/src/lib/api/product-catalogs.ts new file mode 100644 index 0000000..f7d00cd --- /dev/null +++ b/apps/core-web/src/lib/api/product-catalogs.ts @@ -0,0 +1,165 @@ +/** + * Client for `apps/api` `/v1/products/{types,groups,subgroups,brands,units}` + * (see apps/api/src/products/products.controller.ts, products.service.ts and + * packages/database/src/repositories/product-catalogs.ts for the source of + * truth — shapes below are mirrored, not invented). Same "simple catalog" + * convention as `./fiscal.ts` (NCM/CFOP): no physical delete, `active: false` + * via `PATCH` is the "apagar". `includeInactive` defaults to `true` here + * (unlike the bare API default) so the admin catalog screens can show + * inactive rows too, with a toggle to reactivate — same choice as + * `./warehouses.ts`. + */ +import { apiRequest } from "./http.js"; + +/** Response shape — snake_case, exactly as returned by each simple catalog table. */ +export interface SimpleCatalogRow { + id: string; + name: string; + active: boolean; + created_at: string; + updated_at: string; + created_by: string | null; + updated_by: string | null; +} + +export type ProductTypeRow = SimpleCatalogRow; +export type ProductGroupRow = SimpleCatalogRow; +export type BrandRow = SimpleCatalogRow; +export type UnitRow = SimpleCatalogRow; + +/** Subgroups always belong to a group — `group_id` is never patchable (see `UpdateProductSubgroupDto`). */ +export interface ProductSubgroupRow extends SimpleCatalogRow { + group_id: string; +} + +export interface UpdateSimpleCatalogInput { + name?: string; + active?: boolean; +} + +function catalogListPath(base: string, includeInactive: boolean): string { + return includeInactive ? `${base}?includeInactive=true` : base; +} + +// ---- Tipos ---- +export function listProductTypes(accessToken: string, includeInactive = true): Promise { + return apiRequest( + catalogListPath("/products/types", includeInactive), + { method: "GET" }, + { accessToken }, + ); +} +export function createProductType(name: string, accessToken: string): Promise { + return apiRequest( + "/products/types", + { method: "POST", body: JSON.stringify({ name }) }, + { accessToken }, + ); +} +export function updateProductType( + id: string, + input: UpdateSimpleCatalogInput, + accessToken: string, +): Promise { + return apiRequest( + `/products/types/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +// ---- Grupos ---- +export function listProductGroups(accessToken: string, includeInactive = true): Promise { + return apiRequest( + catalogListPath("/products/groups", includeInactive), + { method: "GET" }, + { accessToken }, + ); +} +export function createProductGroup(name: string, accessToken: string): Promise { + return apiRequest( + "/products/groups", + { method: "POST", body: JSON.stringify({ name }) }, + { accessToken }, + ); +} +export function updateProductGroup( + id: string, + input: UpdateSimpleCatalogInput, + accessToken: string, +): Promise { + return apiRequest( + `/products/groups/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +// ---- Subgrupos (sempre escopados a um grupo) ---- +export function listProductSubgroups( + accessToken: string, + options: { groupId?: string; includeInactive?: boolean } = {}, +): Promise { + const { groupId, includeInactive = true } = options; + const params = new URLSearchParams(); + if (groupId) params.set("groupId", groupId); + if (includeInactive) params.set("includeInactive", "true"); + const query = params.toString(); + return apiRequest( + `/products/subgroups${query ? `?${query}` : ""}`, + { method: "GET" }, + { accessToken }, + ); +} +export function createProductSubgroup( + input: { name: string; groupId: string }, + accessToken: string, +): Promise { + return apiRequest( + "/products/subgroups", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} +/** `groupId` is deliberately not accepted here — a subgroup never changes group in this MVP (see backend `UpdateProductSubgroupDto`). */ +export function updateProductSubgroup( + id: string, + input: UpdateSimpleCatalogInput, + accessToken: string, +): Promise { + return apiRequest( + `/products/subgroups/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +// ---- Marcas ---- +export function listBrands(accessToken: string, includeInactive = true): Promise { + return apiRequest(catalogListPath("/products/brands", includeInactive), { method: "GET" }, { accessToken }); +} +export function createBrand(name: string, accessToken: string): Promise { + return apiRequest("/products/brands", { method: "POST", body: JSON.stringify({ name }) }, { accessToken }); +} +export function updateBrand(id: string, input: UpdateSimpleCatalogInput, accessToken: string): Promise { + return apiRequest( + `/products/brands/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +// ---- Unidades ---- +export function listUnits(accessToken: string, includeInactive = true): Promise { + return apiRequest(catalogListPath("/products/units", includeInactive), { method: "GET" }, { accessToken }); +} +export function createUnit(name: string, accessToken: string): Promise { + return apiRequest("/products/units", { method: "POST", body: JSON.stringify({ name }) }, { accessToken }); +} +export function updateUnit(id: string, input: UpdateSimpleCatalogInput, accessToken: string): Promise { + return apiRequest( + `/products/units/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/products.ts b/apps/core-web/src/lib/api/products.ts index 7ec1945..7be39ad 100644 --- a/apps/core-web/src/lib/api/products.ts +++ b/apps/core-web/src/lib/api/products.ts @@ -22,11 +22,16 @@ export interface Product { product_code: number; name: string; description: string | null; - product_type: string; - product_group: string | null; - product_subgroup: string | null; - brand: string | null; - unit: string; + product_type_id: string; + product_type_name: string; + product_group_id: string | null; + product_group_name: string | null; + product_subgroup_id: string | null; + product_subgroup_name: string | null; + brand_id: string | null; + brand_name: string | null; + unit_id: string; + unit_name: string; active: boolean; discontinued: boolean; tracks_stock: boolean; @@ -51,11 +56,12 @@ export interface Product { export interface CreateProductInput { name: string; description?: string; - productType: string; - productGroup?: string; - productSubgroup?: string; - brand?: string; - unit?: string; + productTypeId: string; + productGroupId?: string; + /** `null` explicitly clears it on update (distinct from omitting, which leaves it unchanged) — see ProductFormPage's payload construction. */ + productSubgroupId?: string | null; + brandId?: string; + unitId: string; active?: boolean; discontinued?: boolean; tracksStock?: boolean; diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index 999df57..853d4db 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -10,28 +10,74 @@ function isNavItemActive(href: string, pathname: string): boolean { return pathname === href || pathname.startsWith(`${href}/`); } +interface NavItemDef { + key: string; + label: string; + href: string; + children?: NavItemDef[]; +} + +/** + * Computes `active` bottom-up. Leaves are matched with `isNavItemActive` + * against their own href; when siblings overlap by prefix (e.g. "Itens" + * href=`/products` is a `/`-prefix of "Tipos" href=`/products/types`), only + * the most specific (longest href) match is marked active, so navigating to + * `/products/types` never also lights up "Itens". A group item (has + * `children`) is active exactly when one of its children is — its own + * `href` is a placeholder (it renders as an accordion button, never a + * direct link) and is never matched on its own. + */ +function withActive(items: NavItemDef[], pathname: string): AppShellNavItem[] { + const leafMatches = items.filter((item) => !item.children && isNavItemActive(item.href, pathname)); + const mostSpecific = leafMatches.reduce( + (best, item) => (!best || item.href.length > best.href.length ? item : best), + null, + ); + + return items.map((item) => { + if (item.children) { + const children = withActive(item.children, pathname); + return { ...item, children, active: children.some((child) => child.active) }; + } + return { ...item, active: item === mostSpecific }; + }); +} + +const NAV_ITEM_DEFS: NavItemDef[] = [ + { key: "dashboard", label: "Painel", href: "/" }, + { key: "companies", label: "Empresas", href: "/companies" }, + { + key: "products", + label: "Produtos", + href: "/products", + children: [ + { key: "products-list", label: "Itens", href: "/products" }, + { key: "products-types", label: "Tipos", href: "/products/types" }, + { key: "products-groups", label: "Grupos", href: "/products/groups" }, + { key: "products-brands", label: "Marcas", href: "/products/brands" }, + { key: "products-units", label: "Unidades", href: "/products/units" }, + ], + }, + { key: "resellers", label: "Revendas", href: "/resellers" }, + { key: "leads", label: "CRM", href: "/leads" }, + { key: "quotes", label: "Ofertas", href: "/quotes" }, + { key: "client-registrations", label: "Clientes", href: "/client-registrations" }, + { key: "contracts", label: "Contratos", href: "/contracts" }, + { key: "documents", label: "Documentos", href: "/documents/templates" }, + { key: "signatures", label: "Assinaturas", href: "/signatures" }, + { key: "inventory", label: "Estoque", href: "/inventory/assets" }, + { key: "billing", label: "Faturamento", href: "/billing/subscriptions" }, + { key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" }, + { key: "support", label: "Suporte", href: "/support/tickets" }, + { key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" }, +]; + export function AuthenticatedLayout() { const { user, logout } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const navItems: AppShellNavItem[] = [ - { key: "dashboard", label: "Painel", href: "/" }, - { key: "companies", label: "Empresas", href: "/companies" }, - { key: "products", label: "Produtos", href: "/products" }, - { key: "resellers", label: "Revendas", href: "/resellers" }, - { key: "leads", label: "CRM", href: "/leads" }, - { key: "quotes", label: "Ofertas", href: "/quotes" }, - { key: "client-registrations", label: "Clientes", href: "/client-registrations" }, - { key: "contracts", label: "Contratos", href: "/contracts" }, - { key: "documents", label: "Documentos", href: "/documents/templates" }, - { key: "signatures", label: "Assinaturas", href: "/signatures" }, - { key: "inventory", label: "Estoque", href: "/inventory/assets" }, - { key: "billing", label: "Faturamento", href: "/billing/subscriptions" }, - { key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" }, - { key: "support", label: "Suporte", href: "/support/tickets" }, - { key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" }, - ].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) })); + const navItems = withActive(NAV_ITEM_DEFS, location.pathname); async function handleLogout() { await logout(); diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx index 67ffe0c..05ccc6d 100644 --- a/apps/core-web/src/routes/products/ProductFormPage.tsx +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -2,15 +2,38 @@ import { useEffect, useState } from "react"; import type { FormEvent } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui"; +import type { SelectOption } from "@eden/ui"; import { SERVICE_TYPES, TARIFF_TYPES } from "@eden/domain-shared"; import type { ServiceType, TariffType } from "@eden/domain-shared"; import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js"; import type { CreateProductInput } from "../../lib/api/products.js"; +import { + listBrands, + listProductGroups, + listProductSubgroups, + listProductTypes, + listUnits, +} from "../../lib/api/product-catalogs.js"; +import type { + BrandRow, + ProductGroupRow, + ProductSubgroupRow, + ProductTypeRow, + UnitRow, +} from "../../lib/api/product-catalogs.js"; import { ApiError } from "../../lib/api/http.js"; import { formatCurrencyBRL } from "../../lib/format.js"; import { useAuth } from "../../lib/auth/AuthContext.js"; import { ProductFiscalSection } from "./ProductFiscalSection.js"; +/** Only `active` rows populate a dropdown, except the row currently selected on an existing product — kept visible (even if meanwhile deactivated) so editing never silently blanks the field. */ +function toCatalogOptions( + rows: T[], + selectedId: string, +): SelectOption[] { + return rows.filter((row) => row.active || row.id === selectedId).map((row) => ({ value: row.id, label: row.name })); +} + /** Money fields are edited/stored as integer cents so the mask never loses precision to floating point. */ function centsToDisplay(cents: number): string { return formatCurrencyBRL(cents / 100); @@ -44,11 +67,11 @@ function CurrencyInput({ interface ProductFormState { name: string; description: string; - productType: string; - productGroup: string; - productSubgroup: string; - brand: string; - unit: string; + productTypeId: string; + productGroupId: string; + productSubgroupId: string; + brandId: string; + unitId: string; active: boolean; discontinued: boolean; tracksStock: boolean; @@ -78,11 +101,11 @@ function emptyTariffRatesCents(): ProductFormState["tariffRatesCents"] { const EMPTY_FORM: ProductFormState = { name: "", description: "", - productType: "", - productGroup: "", - productSubgroup: "", - brand: "", - unit: "", + productTypeId: "", + productGroupId: "", + productSubgroupId: "", + brandId: "", + unitId: "", active: true, discontinued: false, tracksStock: false, @@ -128,11 +151,11 @@ export function ProductFormPage() { setForm({ name: product.name, description: product.description ?? "", - productType: product.product_type, - productGroup: product.product_group ?? "", - productSubgroup: product.product_subgroup ?? "", - brand: product.brand ?? "", - unit: product.unit, + productTypeId: product.product_type_id, + productGroupId: product.product_group_id ?? "", + productSubgroupId: product.product_subgroup_id ?? "", + brandId: product.brand_id ?? "", + unitId: product.unit_id, active: product.active, discontinued: product.discontinued, tracksStock: product.tracks_stock, @@ -171,6 +194,75 @@ export function ProductFormPage() { }; }, [id, accessToken]); + const [catalogs, setCatalogs] = useState<{ + types: ProductTypeRow[]; + groups: ProductGroupRow[]; + brands: BrandRow[]; + units: UnitRow[]; + } | null>(null); + const [catalogError, setCatalogError] = useState(null); + + useEffect(() => { + if (!accessToken) return; + let cancelled = false; + Promise.all([listProductTypes(accessToken), listProductGroups(accessToken), listBrands(accessToken), listUnits(accessToken)]) + .then(([types, groups, brands, units]) => { + if (!cancelled) setCatalogs({ types, groups, brands, units }); + }) + .catch((err) => { + if (!cancelled) { + setCatalogError( + err instanceof ApiError + ? err.message + : "Não foi possível carregar os catálogos de tipo/grupo/marca/unidade.", + ); + } + }); + return () => { + cancelled = true; + }; + }, [accessToken]); + + // Subgrupo é sempre escopado a um grupo — o dropdown só é populado depois + // que um grupo é escolhido, refazendo o fetch a cada troca de grupo + // (`groupId=`), nunca a lista geral de subgrupos. + const [subgroups, setSubgroups] = useState([]); + const [subgroupsLoading, setSubgroupsLoading] = useState(false); + + useEffect(() => { + if (!accessToken || !form.productGroupId) { + setSubgroups([]); + return; + } + let cancelled = false; + setSubgroupsLoading(true); + listProductSubgroups(accessToken, { groupId: form.productGroupId }) + .then((rows) => { + if (!cancelled) setSubgroups(rows); + }) + .catch(() => { + if (!cancelled) setSubgroups([]); + }) + .finally(() => { + if (!cancelled) setSubgroupsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [accessToken, form.productGroupId]); + + // Se o subgrupo selecionado não pertence (mais) ao grupo atual — porque o + // grupo acabou de mudar — limpa a seleção em vez de deixar o formulário + // silenciosamente prestes a mandar uma combinação grupo/subgrupo inválida. + useEffect(() => { + if (subgroupsLoading || !form.productSubgroupId) return; + const stillValid = subgroups.some((s) => s.id === form.productSubgroupId); + if (!stillValid) { + setForm((prev) => ({ ...prev, productSubgroupId: "" })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subgroups, subgroupsLoading]); + function updateField(field: K, value: ProductFormState[K]) { setForm((prev) => ({ ...prev, [field]: value })); } @@ -184,11 +276,15 @@ export function ProductFormPage() { const payload: CreateProductInput = { name: form.name.trim(), description: toOptional(form.description), - productType: form.productType.trim(), - productGroup: toOptional(form.productGroup), - productSubgroup: toOptional(form.productSubgroup), - brand: toOptional(form.brand), - unit: toOptional(form.unit), + productTypeId: form.productTypeId, + productGroupId: toOptional(form.productGroupId), + // `null` (not `undefined`) when empty — PATCH treats an omitted + // field as "leave unchanged" but `null` as an explicit clear + // (needed when the group changed and the old subgroup no longer + // applies, per the auto-clear effect above). Harmless for POST. + productSubgroupId: form.productSubgroupId.trim() === "" ? null : form.productSubgroupId.trim(), + brandId: toOptional(form.brandId), + unitId: form.unitId, active: form.active, discontinued: form.discontinued, tracksStock: form.tracksStock, @@ -226,6 +322,12 @@ export function ProductFormPage() { } } + const productTypeOptions = catalogs ? toCatalogOptions(catalogs.types, form.productTypeId) : []; + const unitOptions = catalogs ? toCatalogOptions(catalogs.units, form.unitId) : []; + const groupOptions = catalogs ? toCatalogOptions(catalogs.groups, form.productGroupId) : []; + const brandOptions = catalogs ? toCatalogOptions(catalogs.brands, form.brandId) : []; + const subgroupOptions = toCatalogOptions(subgroups, form.productSubgroupId); + return (
) : null} + {catalogError ? ( + + {catalogError} + + ) : null} + {loading ? (

Carregando…

@@ -264,24 +372,51 @@ export function ProductFormPage() {
updateField("name", e.target.value)} required />
- updateField("productType", e.target.value)} + options={productTypeOptions} + value={form.productTypeId} + onChange={(e) => updateField("productTypeId", e.target.value)} + placeholder={catalogs === null ? "Carregando…" : "Selecione um tipo"} required /> - updateField("unit", e.target.value)} /> - updateField("unitId", e.target.value)} + placeholder={catalogs === null ? "Carregando…" : "Selecione uma unidade"} + required + /> + updateField("productSubgroup", e.target.value)} + options={subgroupOptions} + value={form.productSubgroupId} + onChange={(e) => updateField("productSubgroupId", e.target.value)} + disabled={!form.productGroupId} + placeholder={ + !form.productGroupId + ? "Selecione um grupo primeiro" + : subgroupsLoading + ? "Carregando…" + : "Nenhum" + } + hint={!form.productGroupId ? "Escolha um grupo para habilitar o subgrupo." : undefined} + /> + updateField("brand", e.target.value)} />