feat(products): catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade com submenu e dropdowns

Menu Produtos ganha submenu (AppShell agora suporta itens aninhados) para
cadastrar Tipos/Grupos/Marcas/Unidades; Subgrupo é gerenciado a partir da
tela do Grupo (sempre pertence a um). O cadastro de item passa a usar
dropdowns desses catálogos em vez de texto livre — exatamente o que o
comentário original da migration de products já previa como trabalho
futuro.

Backend: 5 tabelas de catálogo simples (mesmo padrão dos catálogos
fiscais), products ganha as FKs correspondentes com backfill, e
endpoints CRUD (sem exclusão física, active=false é o "apagar").

Corrige também um buraco de integridade real encontrado na integração:
PATCH /v1/products/:id usava COALESCE para os campos de catálogo
opcionais, então não dava para distinguir "não mexer" de "limpar" —
trocar o Grupo de um produto sem tocar no Subgrupo deixava o vínculo
antigo órfão e inconsistente, sem nenhuma validação pegando isso. Agora
a atualização valida o estado efetivo resultante (bloqueia com 400 em
vez de corromper o vínculo) e productSubgroupId: null limpa de verdade.

Suíte: 95/95 verdes (92→95). pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 23:46:14 -03:00
parent 9c14671e19
commit f38f2407ff
39 changed files with 2738 additions and 137 deletions

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateBrandDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateProductGroupDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateProductTypeDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -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 { Type } from "class-transformer";
import { SERVICE_TYPES, type ServiceType } from "@eden/domain-shared"; import { SERVICE_TYPES, type ServiceType } from "@eden/domain-shared";
import { TariffRatesDto } from "./tariff-rate.dto.js"; import { TariffRatesDto } from "./tariff-rate.dto.js";
@@ -10,14 +10,19 @@ export class CreateProductDto {
@IsOptional() @IsString() description?: string; @IsOptional() @IsString() description?: string;
@IsString() @IsUUID()
@MinLength(1) productTypeId!: string;
productType!: string;
@IsOptional() @IsString() productGroup?: string; @IsOptional() @IsUUID() productGroupId?: string;
@IsOptional() @IsString() productSubgroup?: string; // `null` is only meaningful on update (PATCH) — an explicit "remove the
@IsOptional() @IsString() brand?: string; // subgroup" instruction, distinct from omitting the field ("leave
@IsOptional() @IsString() unit?: string; // 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() active?: boolean;
@IsOptional() @IsBoolean() discontinued?: boolean; @IsOptional() @IsBoolean() discontinued?: boolean;

View File

@@ -0,0 +1,9 @@
import { IsBoolean, IsOptional, MinLength, IsString } from "class-validator";
export class CreateUnitDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateBrandDto } from "./create-brand.dto.js";
export class UpdateBrandDto extends PartialType(CreateBrandDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateProductGroupDto } from "./create-product-group.dto.js";
export class UpdateProductGroupDto extends PartialType(CreateProductGroupDto) {}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateProductTypeDto } from "./create-product-type.dto.js";
export class UpdateProductTypeDto extends PartialType(CreateProductTypeDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from "@nestjs/swagger";
import { CreateUnitDto } from "./create-unit.dto.js";
export class UpdateUnitDto extends PartialType(CreateUnitDto) {}

View File

@@ -3,6 +3,16 @@ import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ProductsService } from "./products.service.js"; import { ProductsService } from "./products.service.js";
import { CreateProductDto } from "./dto/create-product.dto.js"; import { CreateProductDto } from "./dto/create-product.dto.js";
import { UpdateProductDto } from "./dto/update-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 { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js"; import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.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 * for companies. Write requires products:edit. Backend always
* recalculates/validates: money fields are format-checked (non-negative) * recalculates/validates: money fields are format-checked (non-negative)
* in the DTO but no pricing logic runs here — that's Quotes, a later phase. * 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") @ApiTags("products")
@ApiBearerAuth() @ApiBearerAuth()
@@ -29,6 +45,126 @@ export class ProductsController {
return this.products.list(includeInactive === "true"); 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(":id")
get(@Param("id") id: string) { get(@Param("id") id: string) {
return this.products.get(id); return this.products.get(id);

View File

@@ -1,8 +1,29 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import { auditRepo, productsRepo, type Product } from "@eden/database"; 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 { emptyTariffRates, type TariffRates } from "@eden/domain-shared";
import type { CreateProductDto } from "./dto/create-product.dto.js"; import type { CreateProductDto } from "./dto/create-product.dto.js";
import type { UpdateProductDto } from "./dto/update-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. */ /** 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<TariffRates> | undefined): TariffRates | undefined { function normalizeTariffRates(partial: Partial<TariffRates> | undefined): TariffRates | undefined {
@@ -10,6 +31,10 @@ function normalizeTariffRates(partial: Partial<TariffRates> | undefined): Tariff
return { ...emptyTariffRates(), ...partial }; return { ...emptyTariffRates(), ...partial };
} }
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505";
}
@Injectable() @Injectable()
export class ProductsService { export class ProductsService {
list(includeInactive: boolean): Promise<Product[]> { list(includeInactive: boolean): Promise<Product[]> {
@@ -23,14 +48,17 @@ export class ProductsService {
} }
async create(dto: CreateProductDto, actorId: string): Promise<Product> { async create(dto: CreateProductDto, actorId: string): Promise<Product> {
await this.assertCatalogReferencesValid(dto);
await this.assertSubgroupConsistent(dto, null);
const product = await productsRepo.createProduct({ const product = await productsRepo.createProduct({
name: dto.name, name: dto.name,
description: dto.description, description: dto.description,
productType: dto.productType, productTypeId: dto.productTypeId,
productGroup: dto.productGroup, productGroupId: dto.productGroupId,
productSubgroup: dto.productSubgroup, productSubgroupId: dto.productSubgroupId,
brand: dto.brand, brandId: dto.brandId,
unit: dto.unit, unitId: dto.unitId,
active: dto.active, active: dto.active,
discontinued: dto.discontinued, discontinued: dto.discontinued,
tracksStock: dto.tracksStock, tracksStock: dto.tracksStock,
@@ -63,16 +91,18 @@ export class ProductsService {
} }
async update(id: string, dto: UpdateProductDto, actorId: string): Promise<Product> { async update(id: string, dto: UpdateProductDto, actorId: string): Promise<Product> {
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, name: dto.name,
description: dto.description, description: dto.description,
productType: dto.productType, productTypeId: dto.productTypeId,
productGroup: dto.productGroup, productGroupId: dto.productGroupId,
productSubgroup: dto.productSubgroup, productSubgroupId: dto.productSubgroupId,
brand: dto.brand, brandId: dto.brandId,
unit: dto.unit, unitId: dto.unitId,
active: dto.active, active: dto.active,
discontinued: dto.discontinued, discontinued: dto.discontinued,
tracksStock: dto.tracksStock, tracksStock: dto.tracksStock,
@@ -93,6 +123,15 @@ export class ProductsService {
}); });
if (!product) throw new NotFoundException("Produto não encontrado"); 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({ await auditRepo.appendAuditEvent({
actorType: "user", actorType: "user",
actorId, actorId,
@@ -104,4 +143,358 @@ export class ProductsService {
return product; 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<Pick<CreateProductDto, "productTypeId" | "unitId" | "productGroupId" | "brandId">>,
): Promise<void> {
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<UpdateProductDto, "productGroupId" | "productSubgroupId">,
current: Pick<Product, "product_group_id" | "product_subgroup_id"> | null,
): Promise<void> {
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<ProductType[]> {
return productCatalogsRepo.listProductTypes(includeInactive);
}
async createProductType(dto: CreateProductTypeDto, user: EdenAuthenticatedUser): Promise<ProductType> {
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<ProductType> {
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<ProductGroup[]> {
return productCatalogsRepo.listProductGroups(includeInactive);
}
async createProductGroup(dto: CreateProductGroupDto, user: EdenAuthenticatedUser): Promise<ProductGroup> {
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<ProductGroup> {
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<ProductSubgroup[]> {
return productCatalogsRepo.listProductSubgroups(groupId, includeInactive);
}
async createProductSubgroup(
dto: CreateProductSubgroupDto,
user: EdenAuthenticatedUser,
): Promise<ProductSubgroup> {
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<ProductSubgroup> {
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<Brand[]> {
return productCatalogsRepo.listBrands(includeInactive);
}
async createBrand(dto: CreateBrandDto, user: EdenAuthenticatedUser): Promise<Brand> {
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<Brand> {
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<Unit[]> {
return productCatalogsRepo.listUnits(includeInactive);
}
async createUnit(dto: CreateUnitDto, user: EdenAuthenticatedUser): Promise<Unit> {
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<Unit> {
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;
}
} }

View File

@@ -1,6 +1,6 @@
import { INestApplication } from "@nestjs/common"; import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; 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 * `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 resellerId: string;
let stfcProductId: string; let stfcProductId: string;
let pabxProductId: string; let pabxProductId: string;
let productTypeId: string;
let unitId: string;
beforeAll(async () => { beforeAll(async () => {
app = await createTestApp(); app = await createTestApp();
@@ -34,12 +36,27 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
.expect(201); .expect(201);
resellerId = reseller.body.id; 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) const stfcProduct = await agent(app)
.post("/api/v1/products") .post("/api/v1/products")
.set(...bearer(admin.accessToken)) .set(...bearer(admin.accessToken))
.send({ .send({
name: "qa-test- Linha STFC", name: "qa-test- Linha STFC",
productType: "service", productTypeId,
unitId,
price0: 200, price0: 200,
price12: 150, price12: 150,
serviceType: "STFC", serviceType: "STFC",
@@ -63,7 +80,7 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
const pabxProduct = await agent(app) const pabxProduct = await agent(app)
.post("/api/v1/products") .post("/api/v1/products")
.set(...bearer(admin.accessToken)) .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); .expect(201);
pabxProductId = pabxProduct.body.id; pabxProductId = pabxProduct.body.id;
}); });

View File

@@ -9,6 +9,7 @@ import {
loginSuperAdmin, loginSuperAdmin,
qaEmail, qaEmail,
qaKey, qaKey,
qaName,
type AuthedTokens, type AuthedTokens,
} from "./setup.js"; } from "./setup.js";
@@ -45,10 +46,27 @@ describe("contracts — edit lock past 'draft'", () => {
.expect(201); .expect(201);
resellerId = reseller.body.id; 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) const product = await agent(app)
.post("/api/v1/products") .post("/api/v1/products")
.set(...bearer(admin.accessToken)) .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); .expect(201);
productId = product.body.id; productId = product.body.id;

View File

@@ -50,7 +50,8 @@ describe("mass-assignment — unexpected fields are rejected, not silently dropp
.set(...bearer(admin.accessToken)) .set(...bearer(admin.accessToken))
.send({ .send({
name: "qa-test- mass assignment product", 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", createdBy: "11111111-1111-1111-1111-111111111111",
}) })
.expect(400); .expect(400);

View File

@@ -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();
});
});
});
});

View File

@@ -118,3 +118,14 @@ export function qaKey(label: string): string {
// apps/api/src/roles/dto/create-role.dto.ts). // apps/api/src/roles/dto/create-role.dto.ts).
return `qa_${label}_${rand}`.slice(0, 30); 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}`;
}

View File

@@ -8,6 +8,10 @@ import { CompaniesListPage } from "./routes/companies/CompaniesListPage.js";
import { CompanyFormPage } from "./routes/companies/CompanyFormPage.js"; import { CompanyFormPage } from "./routes/companies/CompanyFormPage.js";
import { ProductsListPage } from "./routes/products/ProductsListPage.js"; import { ProductsListPage } from "./routes/products/ProductsListPage.js";
import { ProductFormPage } from "./routes/products/ProductFormPage.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 { ResellersListPage } from "./routes/resellers/ResellersListPage.js";
import { ResellerFormPage } from "./routes/resellers/ResellerFormPage.js"; import { ResellerFormPage } from "./routes/resellers/ResellerFormPage.js";
import { LeadsListPage } from "./routes/crm/LeadsListPage.js"; import { LeadsListPage } from "./routes/crm/LeadsListPage.js";
@@ -82,6 +86,10 @@ export function App() {
<Route path="/products" element={<ProductsListPage />} /> <Route path="/products" element={<ProductsListPage />} />
<Route path="/products/new" element={<ProductFormPage />} /> <Route path="/products/new" element={<ProductFormPage />} />
<Route path="/products/:id/edit" element={<ProductFormPage />} /> <Route path="/products/:id/edit" element={<ProductFormPage />} />
<Route path="/products/types" element={<ProductTypesListPage />} />
<Route path="/products/groups" element={<ProductGroupsListPage />} />
<Route path="/products/brands" element={<BrandsListPage />} />
<Route path="/products/units" element={<UnitsListPage />} />
<Route path="/resellers" element={<ResellersListPage />} /> <Route path="/resellers" element={<ResellersListPage />} />
<Route path="/resellers/new" element={<ResellerFormPage />} /> <Route path="/resellers/new" element={<ResellerFormPage />} />

View File

@@ -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<ProductTypeRow[]> {
return apiRequest<ProductTypeRow[]>(
catalogListPath("/products/types", includeInactive),
{ method: "GET" },
{ accessToken },
);
}
export function createProductType(name: string, accessToken: string): Promise<ProductTypeRow> {
return apiRequest<ProductTypeRow>(
"/products/types",
{ method: "POST", body: JSON.stringify({ name }) },
{ accessToken },
);
}
export function updateProductType(
id: string,
input: UpdateSimpleCatalogInput,
accessToken: string,
): Promise<ProductTypeRow> {
return apiRequest<ProductTypeRow>(
`/products/types/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
// ---- Grupos ----
export function listProductGroups(accessToken: string, includeInactive = true): Promise<ProductGroupRow[]> {
return apiRequest<ProductGroupRow[]>(
catalogListPath("/products/groups", includeInactive),
{ method: "GET" },
{ accessToken },
);
}
export function createProductGroup(name: string, accessToken: string): Promise<ProductGroupRow> {
return apiRequest<ProductGroupRow>(
"/products/groups",
{ method: "POST", body: JSON.stringify({ name }) },
{ accessToken },
);
}
export function updateProductGroup(
id: string,
input: UpdateSimpleCatalogInput,
accessToken: string,
): Promise<ProductGroupRow> {
return apiRequest<ProductGroupRow>(
`/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<ProductSubgroupRow[]> {
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<ProductSubgroupRow[]>(
`/products/subgroups${query ? `?${query}` : ""}`,
{ method: "GET" },
{ accessToken },
);
}
export function createProductSubgroup(
input: { name: string; groupId: string },
accessToken: string,
): Promise<ProductSubgroupRow> {
return apiRequest<ProductSubgroupRow>(
"/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<ProductSubgroupRow> {
return apiRequest<ProductSubgroupRow>(
`/products/subgroups/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
// ---- Marcas ----
export function listBrands(accessToken: string, includeInactive = true): Promise<BrandRow[]> {
return apiRequest<BrandRow[]>(catalogListPath("/products/brands", includeInactive), { method: "GET" }, { accessToken });
}
export function createBrand(name: string, accessToken: string): Promise<BrandRow> {
return apiRequest<BrandRow>("/products/brands", { method: "POST", body: JSON.stringify({ name }) }, { accessToken });
}
export function updateBrand(id: string, input: UpdateSimpleCatalogInput, accessToken: string): Promise<BrandRow> {
return apiRequest<BrandRow>(
`/products/brands/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
// ---- Unidades ----
export function listUnits(accessToken: string, includeInactive = true): Promise<UnitRow[]> {
return apiRequest<UnitRow[]>(catalogListPath("/products/units", includeInactive), { method: "GET" }, { accessToken });
}
export function createUnit(name: string, accessToken: string): Promise<UnitRow> {
return apiRequest<UnitRow>("/products/units", { method: "POST", body: JSON.stringify({ name }) }, { accessToken });
}
export function updateUnit(id: string, input: UpdateSimpleCatalogInput, accessToken: string): Promise<UnitRow> {
return apiRequest<UnitRow>(
`/products/units/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -22,11 +22,16 @@ export interface Product {
product_code: number; product_code: number;
name: string; name: string;
description: string | null; description: string | null;
product_type: string; product_type_id: string;
product_group: string | null; product_type_name: string;
product_subgroup: string | null; product_group_id: string | null;
brand: string | null; product_group_name: string | null;
unit: string; 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; active: boolean;
discontinued: boolean; discontinued: boolean;
tracks_stock: boolean; tracks_stock: boolean;
@@ -51,11 +56,12 @@ export interface Product {
export interface CreateProductInput { export interface CreateProductInput {
name: string; name: string;
description?: string; description?: string;
productType: string; productTypeId: string;
productGroup?: string; productGroupId?: string;
productSubgroup?: string; /** `null` explicitly clears it on update (distinct from omitting, which leaves it unchanged) — see ProductFormPage's payload construction. */
brand?: string; productSubgroupId?: string | null;
unit?: string; brandId?: string;
unitId: string;
active?: boolean; active?: boolean;
discontinued?: boolean; discontinued?: boolean;
tracksStock?: boolean; tracksStock?: boolean;

View File

@@ -10,28 +10,74 @@ function isNavItemActive(href: string, pathname: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`); 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<NavItemDef | null>(
(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() { export function AuthenticatedLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const navItems: AppShellNavItem[] = [ const navItems = withActive(NAV_ITEM_DEFS, location.pathname);
{ 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) }));
async function handleLogout() { async function handleLogout() {
await logout(); await logout();

View File

@@ -2,15 +2,38 @@ import { useEffect, useState } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui"; 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 { SERVICE_TYPES, TARIFF_TYPES } from "@eden/domain-shared";
import type { ServiceType, TariffType } from "@eden/domain-shared"; import type { ServiceType, TariffType } from "@eden/domain-shared";
import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js"; import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js";
import type { CreateProductInput } 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 { ApiError } from "../../lib/api/http.js";
import { formatCurrencyBRL } from "../../lib/format.js"; import { formatCurrencyBRL } from "../../lib/format.js";
import { useAuth } from "../../lib/auth/AuthContext.js"; import { useAuth } from "../../lib/auth/AuthContext.js";
import { ProductFiscalSection } from "./ProductFiscalSection.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<T extends { id: string; name: string; active: boolean }>(
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. */ /** Money fields are edited/stored as integer cents so the mask never loses precision to floating point. */
function centsToDisplay(cents: number): string { function centsToDisplay(cents: number): string {
return formatCurrencyBRL(cents / 100); return formatCurrencyBRL(cents / 100);
@@ -44,11 +67,11 @@ function CurrencyInput({
interface ProductFormState { interface ProductFormState {
name: string; name: string;
description: string; description: string;
productType: string; productTypeId: string;
productGroup: string; productGroupId: string;
productSubgroup: string; productSubgroupId: string;
brand: string; brandId: string;
unit: string; unitId: string;
active: boolean; active: boolean;
discontinued: boolean; discontinued: boolean;
tracksStock: boolean; tracksStock: boolean;
@@ -78,11 +101,11 @@ function emptyTariffRatesCents(): ProductFormState["tariffRatesCents"] {
const EMPTY_FORM: ProductFormState = { const EMPTY_FORM: ProductFormState = {
name: "", name: "",
description: "", description: "",
productType: "", productTypeId: "",
productGroup: "", productGroupId: "",
productSubgroup: "", productSubgroupId: "",
brand: "", brandId: "",
unit: "", unitId: "",
active: true, active: true,
discontinued: false, discontinued: false,
tracksStock: false, tracksStock: false,
@@ -128,11 +151,11 @@ export function ProductFormPage() {
setForm({ setForm({
name: product.name, name: product.name,
description: product.description ?? "", description: product.description ?? "",
productType: product.product_type, productTypeId: product.product_type_id,
productGroup: product.product_group ?? "", productGroupId: product.product_group_id ?? "",
productSubgroup: product.product_subgroup ?? "", productSubgroupId: product.product_subgroup_id ?? "",
brand: product.brand ?? "", brandId: product.brand_id ?? "",
unit: product.unit, unitId: product.unit_id,
active: product.active, active: product.active,
discontinued: product.discontinued, discontinued: product.discontinued,
tracksStock: product.tracks_stock, tracksStock: product.tracks_stock,
@@ -171,6 +194,75 @@ export function ProductFormPage() {
}; };
}, [id, accessToken]); }, [id, accessToken]);
const [catalogs, setCatalogs] = useState<{
types: ProductTypeRow[];
groups: ProductGroupRow[];
brands: BrandRow[];
units: UnitRow[];
} | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(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=<grupo>`), nunca a lista geral de subgrupos.
const [subgroups, setSubgroups] = useState<ProductSubgroupRow[]>([]);
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<K extends keyof ProductFormState>(field: K, value: ProductFormState[K]) { function updateField<K extends keyof ProductFormState>(field: K, value: ProductFormState[K]) {
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
} }
@@ -184,11 +276,15 @@ export function ProductFormPage() {
const payload: CreateProductInput = { const payload: CreateProductInput = {
name: form.name.trim(), name: form.name.trim(),
description: toOptional(form.description), description: toOptional(form.description),
productType: form.productType.trim(), productTypeId: form.productTypeId,
productGroup: toOptional(form.productGroup), productGroupId: toOptional(form.productGroupId),
productSubgroup: toOptional(form.productSubgroup), // `null` (not `undefined`) when empty — PATCH treats an omitted
brand: toOptional(form.brand), // field as "leave unchanged" but `null` as an explicit clear
unit: toOptional(form.unit), // (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, active: form.active,
discontinued: form.discontinued, discontinued: form.discontinued,
tracksStock: form.tracksStock, 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 ( return (
<div> <div>
<PageHeader <PageHeader
@@ -253,6 +355,12 @@ export function ProductFormPage() {
</Alert> </Alert>
) : null} ) : null}
{catalogError ? (
<Alert variant="error" className="mb-4">
{catalogError}
</Alert>
) : null}
{loading ? ( {loading ? (
<Card> <Card>
<p className="text-sm text-default">Carregando…</p> <p className="text-sm text-default">Carregando…</p>
@@ -264,24 +372,51 @@ export function ProductFormPage() {
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required /> <Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
</div> </div>
<Input <Select
label="Tipo" label="Tipo"
value={form.productType} options={productTypeOptions}
onChange={(e) => updateField("productType", e.target.value)} value={form.productTypeId}
onChange={(e) => updateField("productTypeId", e.target.value)}
placeholder={catalogs === null ? "Carregando…" : "Selecione um tipo"}
required required
/> />
<Input label="Unidade" placeholder="unidade" value={form.unit} onChange={(e) => updateField("unit", e.target.value)} /> <Select
<Input label="Unidade"
options={unitOptions}
value={form.unitId}
onChange={(e) => updateField("unitId", e.target.value)}
placeholder={catalogs === null ? "Carregando…" : "Selecione uma unidade"}
required
/>
<Select
label="Grupo" label="Grupo"
value={form.productGroup} options={groupOptions}
onChange={(e) => updateField("productGroup", e.target.value)} value={form.productGroupId}
onChange={(e) => updateField("productGroupId", e.target.value)}
placeholder={catalogs === null ? "Carregando…" : "Nenhum"}
/> />
<Input <Select
label="Subgrupo" label="Subgrupo"
value={form.productSubgroup} options={subgroupOptions}
onChange={(e) => updateField("productSubgroup", e.target.value)} 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}
/>
<Select
label="Marca"
options={brandOptions}
value={form.brandId}
onChange={(e) => updateField("brandId", e.target.value)}
placeholder={catalogs === null ? "Carregando…" : "Nenhuma"}
/> />
<Input label="Marca" value={form.brand} onChange={(e) => updateField("brand", e.target.value)} />
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="mb-1 block text-sm font-semibold text-gray-900" htmlFor="product-description"> <label className="mb-1 block text-sm font-semibold text-gray-900" htmlFor="product-description">
Descrição Descrição

View File

@@ -44,7 +44,7 @@ export function ProductsListPage() {
const columns: Array<TableColumn<Product>> = [ const columns: Array<TableColumn<Product>> = [
{ key: "code", header: "Código", render: (product) => `#${product.product_code}` }, { key: "code", header: "Código", render: (product) => `#${product.product_code}` },
{ key: "name", header: "Nome", render: (product) => <span className="font-semibold text-title">{product.name}</span> }, { key: "name", header: "Nome", render: (product) => <span className="font-semibold text-title">{product.name}</span> },
{ key: "type", header: "Tipo", render: (product) => product.product_type }, { key: "type", header: "Tipo", render: (product) => product.product_type_name },
...PRICE_COLUMNS.map( ...PRICE_COLUMNS.map(
(column): TableColumn<Product> => ({ (column): TableColumn<Product> => ({
key: column.key, key: column.key,

View File

@@ -0,0 +1,34 @@
import { PageHeader } from "@eden/ui";
import { createBrand, listBrands, updateBrand } from "../../../lib/api/product-catalogs.js";
import type { BrandRow } from "../../../lib/api/product-catalogs.js";
import { useAuth } from "../../../lib/auth/AuthContext.js";
import { SimpleCatalogCard } from "./SimpleCatalogCard.js";
import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js";
/** Catálogo de Marca — dropdown opcional em `ProductFormPage.tsx`. */
export function BrandsListPage() {
const { accessToken } = useAuth();
const { items, error, formError, creating, togglingId, create, toggleActive } = useSimpleCatalogCrud<BrandRow>(
accessToken,
{ list: listBrands, create: createBrand, update: updateBrand },
"Não foi possível carregar as marcas.",
);
return (
<div>
<PageHeader title="Produtos — Marcas" description="Marca/fabricante do produto. Campo opcional no cadastro de produto." />
<SimpleCatalogCard
items={items}
error={error}
formError={formError}
creating={creating}
togglingId={togglingId}
onCreate={create}
onToggleActive={toggleActive}
namePlaceholder="ex.: Intelbras"
createLabel="Adicionar marca"
emptyMessage="Nenhuma marca cadastrada."
/>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Alert, Button, Input } from "@eden/ui";
/** Inline "add by name" form shared by all five product catalogs (Tipos/Grupos/Subgrupos/Marcas/Unidades) — every one of them creates with a single required field. */
export function CatalogCreateForm({
placeholder,
buttonLabel = "Adicionar",
creating,
error,
onCreate,
}: {
placeholder: string;
buttonLabel?: string;
creating: boolean;
error: string | null;
onCreate: (name: string) => Promise<boolean>;
}) {
const [name, setName] = useState("");
async function handleSubmit(event: FormEvent) {
event.preventDefault();
const trimmed = name.trim();
if (!trimmed) return;
const created = await onCreate(trimmed);
if (created) setName("");
}
return (
<div className="mb-4">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<form className="flex flex-col gap-3 sm:flex-row sm:items-end" onSubmit={handleSubmit}>
<div className="flex-1">
<Input
label="Nome"
placeholder={placeholder}
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<Button type="submit" loading={creating}>
{buttonLabel}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { Badge, Button, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import type { SimpleCatalogRow } from "../../../lib/api/product-catalogs.js";
/**
* Name + status + "Ativar"/"Desativar" toggle — shared row shape for all
* five product catalogs. No physical delete anywhere here (backend
* convention, see `product-catalogs.ts`), so deactivating is the only
* destructive-looking action — gated by a light `window.confirm` (no modal
* component exists yet in `@eden/ui`, same tradeoff other catalog screens
* in this codebase currently make).
*/
export function CatalogTable<T extends SimpleCatalogRow>({
items,
togglingId,
onToggleActive,
emptyMessage = "Nenhum registro cadastrado.",
extraColumn,
}: {
items: T[];
togglingId: string | null;
onToggleActive: (item: T) => void;
emptyMessage?: string;
extraColumn?: TableColumn<T>;
}) {
const columns: Array<TableColumn<T>> = [
{
key: "name",
header: "Nome",
render: (item) => <span className="font-semibold text-title">{item.name}</span>,
},
{
key: "status",
header: "Status",
render: (item) => (
<Badge variant={item.active ? "success" : "neutral"}>{item.active ? "Ativo" : "Inativo"}</Badge>
),
},
...(extraColumn ? [extraColumn] : []),
{
key: "actions",
header: "",
className: "text-right",
render: (item) => (
<Button
variant={item.active ? "danger" : "secondary"}
size="sm"
type="button"
loading={togglingId === item.id}
onClick={() => {
if (
item.active &&
!window.confirm(`Desativar "${item.name}"? Ele deixa de aparecer para novos cadastros.`)
) {
return;
}
onToggleActive(item);
}}
>
{item.active ? "Desativar" : "Ativar"}
</Button>
),
},
];
return <Table columns={columns} data={items} rowKey={(item) => item.id} emptyMessage={emptyMessage} />;
}

View File

@@ -0,0 +1,56 @@
import { Alert, Card } from "@eden/ui";
import { createProductSubgroup, listProductSubgroups, updateProductSubgroup } from "../../../lib/api/product-catalogs.js";
import type { ProductGroupRow, ProductSubgroupRow } from "../../../lib/api/product-catalogs.js";
import { useAuth } from "../../../lib/auth/AuthContext.js";
import { CatalogCreateForm } from "./CatalogCreateForm.js";
import { CatalogTable } from "./CatalogTable.js";
import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js";
/**
* Subgrupos de um grupo específico — não existe rota/entrada de menu
* própria para subgrupos (eles sempre pertencem a um grupo), então este
* painel só aparece a partir da linha expandida de um grupo em
* `ProductGroupsListPage.tsx`. O `groupId` já vem implícito de `group.id`,
* então o formulário de criação nunca pede "qual grupo" — impossível
* cadastrar um subgrupo órfão por aqui.
*/
export function GroupSubgroupsPanel({ group }: { group: ProductGroupRow }) {
const { accessToken } = useAuth();
const { items, error, formError, creating, togglingId, create, toggleActive } = useSimpleCatalogCrud<ProductSubgroupRow>(
accessToken,
{
list: (token) => listProductSubgroups(token, { groupId: group.id }),
create: (name, token) => createProductSubgroup({ name, groupId: group.id }, token),
update: updateProductSubgroup,
},
"Não foi possível carregar os subgrupos.",
[group.id],
);
return (
<Card title={`Subgrupos — ${group.name}`} className="mt-3">
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<CatalogCreateForm
placeholder="ex.: Fibra residencial"
buttonLabel="Adicionar subgrupo"
creating={creating}
error={formError}
onCreate={create}
/>
{items === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<CatalogTable
items={items ?? []}
togglingId={togglingId}
onToggleActive={toggleActive}
emptyMessage="Nenhum subgrupo cadastrado para este grupo."
/>
)}
</Card>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { Alert, Button, Card, PageHeader } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { createProductGroup, listProductGroups, updateProductGroup } from "../../../lib/api/product-catalogs.js";
import type { ProductGroupRow } from "../../../lib/api/product-catalogs.js";
import { useAuth } from "../../../lib/auth/AuthContext.js";
import { CatalogCreateForm } from "./CatalogCreateForm.js";
import { CatalogTable } from "./CatalogTable.js";
import { GroupSubgroupsPanel } from "./GroupSubgroupsPanel.js";
import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js";
/**
* Catálogo de Grupo — dropdown opcional em `ProductFormPage.tsx`. Também é
* a única tela que expõe os Subgrupos (sem entrada própria no menu, ver
* `AuthenticatedLayout.tsx`): expandir a linha de um grupo abre
* `GroupSubgroupsPanel` abaixo da tabela, escopado a esse grupo.
*/
export function ProductGroupsListPage() {
const { accessToken } = useAuth();
const { items, error, formError, creating, togglingId, create, toggleActive } = useSimpleCatalogCrud<ProductGroupRow>(
accessToken,
{ list: listProductGroups, create: createProductGroup, update: updateProductGroup },
"Não foi possível carregar os grupos de produto.",
);
const [expandedGroupId, setExpandedGroupId] = useState<string | null>(null);
const expandedGroup = (items ?? []).find((group) => group.id === expandedGroupId) ?? null;
const subgroupsColumn: TableColumn<ProductGroupRow> = {
key: "subgroups",
header: "",
className: "text-right",
render: (group) => (
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => setExpandedGroupId((current) => (current === group.id ? null : group.id))}
>
{expandedGroupId === group.id ? "Ocultar subgrupos" : "Ver subgrupos"}
</Button>
),
};
return (
<div>
<PageHeader
title="Produtos — Grupos"
description="Grupo do produto/serviço. Campo opcional no cadastro de produto — expanda um grupo abaixo para gerenciar seus subgrupos."
/>
<Card>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<CatalogCreateForm
placeholder="ex.: Banda larga"
buttonLabel="Adicionar grupo"
creating={creating}
error={formError}
onCreate={create}
/>
{items === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<CatalogTable
items={items ?? []}
togglingId={togglingId}
onToggleActive={toggleActive}
emptyMessage="Nenhum grupo de produto cadastrado."
extraColumn={subgroupsColumn}
/>
)}
</Card>
{expandedGroup ? <GroupSubgroupsPanel group={expandedGroup} /> : null}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { PageHeader } from "@eden/ui";
import { createProductType, listProductTypes, updateProductType } from "../../../lib/api/product-catalogs.js";
import type { ProductTypeRow } from "../../../lib/api/product-catalogs.js";
import { useAuth } from "../../../lib/auth/AuthContext.js";
import { SimpleCatalogCard } from "./SimpleCatalogCard.js";
import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js";
/** Catálogo de Tipo de produto — usado como dropdown obrigatório em `ProductFormPage.tsx`. Mesma estrutura de `fiscal/NcmListPage.tsx`, adaptada ao padrão "name + active" dos catálogos de produto (`product-catalogs.ts`). */
export function ProductTypesListPage() {
const { accessToken } = useAuth();
const { items, error, formError, creating, togglingId, create, toggleActive } = useSimpleCatalogCrud<ProductTypeRow>(
accessToken,
{ list: listProductTypes, create: createProductType, update: updateProductType },
"Não foi possível carregar os tipos de produto.",
);
return (
<div>
<PageHeader
title="Produtos — Tipos"
description="Tipo do produto/serviço (ex.: Internet, Telefonia, Equipamento). Campo obrigatório no cadastro de produto."
/>
<SimpleCatalogCard
items={items}
error={error}
formError={formError}
creating={creating}
togglingId={togglingId}
onCreate={create}
onToggleActive={toggleActive}
namePlaceholder="ex.: Internet"
createLabel="Adicionar tipo"
emptyMessage="Nenhum tipo de produto cadastrado."
/>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { Alert, Card } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import type { SimpleCatalogRow } from "../../../lib/api/product-catalogs.js";
import { CatalogCreateForm } from "./CatalogCreateForm.js";
import { CatalogTable } from "./CatalogTable.js";
/** `Card` wrapping the create-form + table pair — used as-is by Tipos/Marcas/Unidades; Grupos composes the same two pieces manually to also fit the subgrupos panel below. */
export function SimpleCatalogCard<T extends SimpleCatalogRow>({
items,
error,
formError,
creating,
togglingId,
onCreate,
onToggleActive,
namePlaceholder,
createLabel,
emptyMessage,
extraColumn,
}: {
items: T[] | null;
error: string | null;
formError: string | null;
creating: boolean;
togglingId: string | null;
onCreate: (name: string) => Promise<boolean>;
onToggleActive: (item: T) => void;
namePlaceholder: string;
createLabel?: string;
emptyMessage?: string;
extraColumn?: TableColumn<T>;
}) {
return (
<Card>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<CatalogCreateForm
placeholder={namePlaceholder}
buttonLabel={createLabel}
creating={creating}
error={formError}
onCreate={onCreate}
/>
{items === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<CatalogTable
items={items ?? []}
togglingId={togglingId}
onToggleActive={onToggleActive}
emptyMessage={emptyMessage}
extraColumn={extraColumn}
/>
)}
</Card>
);
}

View File

@@ -0,0 +1,37 @@
import { PageHeader } from "@eden/ui";
import { createUnit, listUnits, updateUnit } from "../../../lib/api/product-catalogs.js";
import type { UnitRow } from "../../../lib/api/product-catalogs.js";
import { useAuth } from "../../../lib/auth/AuthContext.js";
import { SimpleCatalogCard } from "./SimpleCatalogCard.js";
import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js";
/** Catálogo de Unidade — dropdown obrigatório em `ProductFormPage.tsx`. */
export function UnitsListPage() {
const { accessToken } = useAuth();
const { items, error, formError, creating, togglingId, create, toggleActive } = useSimpleCatalogCrud<UnitRow>(
accessToken,
{ list: listUnits, create: createUnit, update: updateUnit },
"Não foi possível carregar as unidades.",
);
return (
<div>
<PageHeader
title="Produtos — Unidades"
description="Unidade de medida do produto (ex.: unidade, mês, ponto). Campo obrigatório no cadastro de produto."
/>
<SimpleCatalogCard
items={items}
error={error}
formError={formError}
creating={creating}
togglingId={togglingId}
onCreate={create}
onToggleActive={toggleActive}
namePlaceholder="ex.: Unidade"
createLabel="Adicionar unidade"
emptyMessage="Nenhuma unidade cadastrada."
/>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "../../../lib/api/http.js";
import type { SimpleCatalogRow, UpdateSimpleCatalogInput } from "../../../lib/api/product-catalogs.js";
export interface SimpleCatalogActions<T extends SimpleCatalogRow> {
list: (accessToken: string) => Promise<T[]>;
create: (name: string, accessToken: string) => Promise<T>;
update: (id: string, input: UpdateSimpleCatalogInput, accessToken: string) => Promise<T>;
}
/**
* Shared load/create/toggle-active state machine for the five product
* catalogs (Tipos/Grupos/Subgrupos/Marcas/Unidades) — they're all the exact
* same shape (name + active, no physical delete, 409 on duplicate name), so
* the loading/error/create/toggle logic is written once here instead of
* copy-pasted per screen (only the API calls and labels differ).
* `deps` lets a scoped catalog (subgroups filtered by a `groupId`) reload
* when the scope changes, same as any other `useCallback` dependency.
*/
export function useSimpleCatalogCrud<T extends SimpleCatalogRow>(
accessToken: string | null,
actions: SimpleCatalogActions<T>,
loadErrorFallback: string,
deps: unknown[] = [],
) {
const [items, setItems] = useState<T[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const load = useCallback(() => {
if (!accessToken) return;
setError(null);
actions
.list(accessToken)
.then(setItems)
.catch((err) => setError(err instanceof ApiError ? err.message : loadErrorFallback));
// `actions` is a plain object of stable module-level functions passed fresh
// at each call site — only `accessToken`/`loadErrorFallback`/`deps` actually vary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accessToken, loadErrorFallback, ...deps]);
useEffect(load, [load]);
async function create(name: string): Promise<boolean> {
if (!accessToken) return false;
setFormError(null);
setCreating(true);
try {
await actions.create(name, accessToken);
load();
return true;
} catch (err) {
setFormError(err instanceof ApiError ? err.message : "Não foi possível salvar. Tente novamente.");
return false;
} finally {
setCreating(false);
}
}
async function toggleActive(item: T): Promise<void> {
if (!accessToken) return;
setTogglingId(item.id);
setError(null);
try {
await actions.update(item.id, { active: !item.active }, accessToken);
load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível atualizar o status.");
} finally {
setTogglingId(null);
}
}
return { items, error, formError, creating, togglingId, create, toggleActive, reload: load };
}

View File

@@ -617,3 +617,32 @@ Cobertos em `test/signature-envelope-flow.spec.ts` (72/72 antes da leva de rate
### Pendências ### Pendências
`DECLINED` (recusa de assinatura) segue sem rota que o produza — reservado no schema, mesma lacuna do legado. `contract_versions`/`contract_amendments` (ADR-0006, adiados desde a leva de trava de edição de contrato) agora estão desbloqueados — o fluxo de assinatura já existe — mas ainda não iniciados. `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela própria, incluindo nada do módulo de assinatura (fora de escopo desta leva). `DECLINED` (recusa de assinatura) segue sem rota que o produza — reservado no schema, mesma lacuna do legado. `contract_versions`/`contract_amendments` (ADR-0006, adiados desde a leva de trava de edição de contrato) agora estão desbloqueados — o fluxo de assinatura já existe — mas ainda não iniciados. `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela própria, incluindo nada do módulo de assinatura (fora de escopo desta leva).
---
## Catálogos de Produto (Tipo/Grupo/Subgrupo/Marca/Unidade) — submenu, telas e dropdowns no cadastro de item
Data: 2026-09-17. Usuário pediu: "o menu produtos precisa ter sub menus, aonde cadastraremos os grupos subgrupos marcas e tipo e unidades e dentro do cadastro do item ai essas informações sera apenas dropdown dos cadastros feitos anteriormente." O próprio cabeçalho da migration original de `products` (Fase 2) já previa isso como trabalho futuro ("`product_type` is free text for now... tightening it to a CHECK/lookup table is future work"). Confirmado antes de começar: a tabela `products` só tinha 37 linhas, todas de teste/QA — backfill trivial, sem dado histórico real em risco.
### Backend (agente `eden-commercial`)
Migration `packages/database/migrations/1789694035000_product-catalogs.cjs`: 5 tabelas novas no formato "catálogo simples" (mesmo shape de `fiscal_ncm`/`fiscal_cfop`, sem os campos de sincronização externa — são catálogos 100% manuais) — `product_types`, `product_groups`, `product_subgroups` (FK `group_id → product_groups`, `ON DELETE RESTRICT`), `brands`, `units` (seedada com `'Unidade'`). `products` ganha `product_type_id`/`product_group_id`/`product_subgroup_id`/`brand_id`/`unit_id`, backfill a partir das colunas TEXT antigas (`product_type`/`unit` sempre presentes viram `NOT NULL`), colunas antigas dropadas. `down` reversível de verdade, testado (`migrate` → `migrate:down` → `migrate` de novo).
Endpoints novos (mesmo controller de `products`, padrão de agrupamento do `FiscalController`): `GET/POST /v1/products/{types,groups,brands,units}`, `PATCH .../:id`, `GET/POST /v1/products/subgroups` (`groupId` filtra a listagem, obrigatório no create), `PATCH /v1/products/subgroups/:id`. Leitura aberta a qualquer autenticado, escrita exige `products:edit` — mesma regra já documentada no `ProductsController`. Sem rota de exclusão física em nenhum dos 5 (mesmo padrão dos catálogos fiscais) — `active=false` é o "apagar". `GET /v1/products`/`GET /v1/products/:id` agora fazem JOIN e devolvem `*_name` resolvido ao lado de cada `*_id`. `CreateProductDto`/`UpdateProductDto` trocaram os campos de texto livre por `productTypeId`/`productGroupId`/`productSubgroupId`/`brandId`/`unitId` (UUID), com validação de existência e de consistência subgrupo↔grupo.
**Achado real corrigido diretamente por mim (não delegado)**: o agente de frontend, ao integrar, encontrou e reportou com precisão um buraco de integridade de dados que o `eden-commercial` tinha deixado passar — `PATCH /v1/products/:id` usa `COALESCE` para os campos opcionais, então não existia forma de expressar "limpar este campo" distinta de "não mexer nele" (ambos colapsavam no mesmo parâmetro `NULL` do SQL). Consequência real: editar um produto trocando o Grupo sem tocar no Subgrupo deixava o `product_subgroup_id` antigo órfão, agora inconsistente com o novo grupo, sem nenhuma validação pegando isso (a checagem de consistência só rodava quando `productSubgroupId` vinha no mesmo PATCH). Corrigido em `apps/api/src/products/products.service.ts`/`packages/database/src/repositories/products.ts`: `productSubgroupId: null` agora é uma instrução explícita de "remover" (nunca tenta buscar um subgrupo com id `null`), a validação de update passou a checar o estado **efetivo resultante** (valor do DTO onde informado, senão o valor atual do produto) — então trocar de grupo sem resolver o subgrupo agora é bloqueado com 400 claro em vez de corromper o vínculo silenciosamente — e uma função de repositório dedicada (`clearProductSubgroup`) contorna a limitação do `COALESCE` para esse campo especificamente. Frontend (`ProductFormPage.tsx`) ajustado para mandar `null` explícito (não `undefined`) quando o campo é limpo, e o aviso que o agente tinha colocado como paliativo foi removido — o problema está resolvido de verdade, não só sinalizado. Não estendi o mesmo tratamento a `productGroupId`/`brandId` (limitação de "não dá para limpar de volta pra vazio via PATCH" pré-existente e mais ampla no código, sem risco de invariante quebrada — fora de escopo).
3 testes novos em `apps/api/test/product-catalogs.spec.ts` cobrindo exatamente esse ciclo (bloqueio, limpeza explícita, troca de grupo bem-sucedida depois). Suíte completa: 95/95 verdes (92→95, incluindo os 15 do agente + os 3 meus).
### Frontend (agente `eden-frontend`)
`packages/ui/src/components/AppShell.tsx`: `AppShellNavItem` ganhou `children?` — item com filhos vira accordion (chevron, auto-expande quando a rota atual é de um filho), sem dependência nova. Verificado sem regressão nos 3 apps (`core-web`/`reseller-web`/`subscriber-web` usam o mesmo componente).
Menu "Produtos" ganhou submenu: Itens/Tipos/Grupos/Marcas/Unidades (Subgrupo deliberadamente sem entrada própria — sempre gerenciado a partir da tela de um Grupo específico, via painel "Ver subgrupos", já que subgrupo não existe fora do contexto de um grupo). 4 telas novas em `apps/core-web/src/routes/products/catalogs/` reaproveitando o padrão já usado em `fiscal/NcmListPage.tsx` (lista + formulário inline de criação + toggle ativo/inativo). `ProductFormPage.tsx`: Tipo/Unidade/Grupo/Subgrupo/Marca viraram dropdowns (Subgrupo desabilitado até escolher um Grupo, refaz fetch por `groupId` a cada troca, limpa a seleção se deixar de pertencer ao grupo novo — e agora manda `null` explícito nesse caso, fechando o ciclo com a correção de backend acima).
### Verificação
`pnpm turbo run typecheck build` 28/28 (verificado de forma independente por mim antes e depois da correção do bug de COALESCE). Suíte de testes 95/95. Testado ao vivo via `curl` contra `eden-api`/`eden-core` reais, através do proxy same-origin: criação de tipo/grupo/subgrupo/marca/unidade, criação de produto referenciando os 5, o 400 exato de "grupo incompatível" ao trocar de grupo sem resolver o subgrupo, `productSubgroupId: null` limpando de verdade (confirmado por re-GET), e a troca de grupo funcionando depois de limpo. Rotas `/products/types`, `/products/groups`, `/products/brands`, `/products/units` respondendo 200 pelo proxy (porta 3001). Linhas de teste (`qa-test-*`/`smoke-test-*`) desativadas nos 5 catálogos e no produto de teste ao final — estado ativo ficou limpo (`service`/`telecom` em Tipos, `Unidade` em Unidades, Grupos/Subgrupos/Marcas vazios, prontos para o usuário cadastrar os seus).
### Pendências
Suítes de teste futuras vão continuar acrescentando linhas `qa-test-*` permanentes nesses 5 catálogos (sem rota de exclusão física, mesmo padrão aceito para os catálogos fiscais) — precisa de desativação manual periódica até (se algum dia for justificado) existir uma rota de exclusão física ou um processo de limpeza dedicado. "Não dá para limpar Grupo/Marca de volta para vazio via PATCH" continua uma limitação pré-existente e mais ampla do padrão `COALESCE` usado em `updateProduct` — não corrigida (sem risco de invariante quebrada, ao contrário do caso do subgrupo).

View File

@@ -0,0 +1,260 @@
/**
* Products bounded context, follow-up slice: turns Tipo/Grupo/Subgrupo/
* Marca/Unidade from free TEXT columns on `products` (see
* 1788438907074_products.cjs header: "`product_type` is free text for
* now ... tightening it to a CHECK/lookup table is future work") into
* five first-class, cadastrable catalogs — `product_types`, `product_groups`,
* `product_subgroups`, `brands`, `units`. Same "simple catalog" shape
* already used by `fiscal_ncm`/`fiscal_cfop` (1788457368744_fiscal.cjs),
* minus the `source`/`source_version`/`metadata` sync-log columns: unlike
* the fiscal catalogs, these five are never imported from an external
* source — they are 100% manually curated by the ERP user, so those
* columns would just sit unused.
*
* `product_subgroups` is the one catalog with an extra shape difference:
* it always belongs to a `product_groups` row (`group_id NOT NULL`,
* `ON DELETE RESTRICT` — a group in use by a subgroup can't be deleted
* out from under it, mirroring every other FK to these catalogs from
* `products`). Uniqueness is case-insensitive and, for subgroups, scoped
* to the owning group: the same subgroup name may exist under two
* different groups, but not twice under the same one.
*
* Data migration note (verified against the real dev/QA Postgres before
* writing this migration): `products` has 37 rows today, all
* test/QA data (`qa-test-`/`Live Test` fixtures plus one manual "Link
* Dedicado 100Mbps" row). `product_group`/`product_subgroup`/`brand` are
* empty on every row; `unit` is always `'unidade'`; `product_type` is only
* ever `'service'` or `'telecom'`. Given that, the backfill below is
* intentionally the simplest thing that is still correct — no need for a
* sophisticated historical-data-preservation strategy for data that never
* existed. `product_groups`/`product_subgroups` backfill is best-effort:
* since the group->subgroup relationship never existed in the legacy TEXT
* columns (there was no FK to reconstruct it from), any distinct
* `product_subgroup` value found without a way to attribute it to a
* specific group is simply left unmigrated — the affected product's new
* `product_subgroup_id` stays NULL rather than inventing a placeholder
* "generic" group the user never asked for. In practice this branch is
* moot today (no environment has a populated `product_subgroup`), but the
* decision is documented here for whoever re-reads this after real data
* exists.
*
* `product_type_id`/`unit_id` end up NOT NULL (mirroring the TEXT columns
* they replace, which were both `NOT NULL`); `product_group_id`/
* `product_subgroup_id`/`brand_id` stay nullable, exactly like the TEXT
* columns they replace were optional.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- product_types / product_groups / brands / units — identical "simple
-- catalog" shape, manually curated (no source/source_version/metadata,
-- unlike the fiscal catalogs — these are never imported).
-- =====================================================================
CREATE TABLE product_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id)
);
COMMENT ON TABLE product_types IS
'Catálogo de Tipos de produto (eden.md §2 §1.1) — antes TEXT livre em products.product_type. Cadastro manual, sem exclusão física (active=false é o "apagar"). Leitura aberta a qualquer autenticado, escrita exige products:edit.';
CREATE UNIQUE INDEX idx_product_types_name ON product_types (lower(name));
CREATE INDEX idx_product_types_active ON product_types (active);
CREATE TRIGGER trg_product_types_updated_at BEFORE UPDATE ON product_types
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE product_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id)
);
COMMENT ON TABLE product_groups IS
'Catálogo de Grupos de produto — antes TEXT livre em products.product_group. Cadastro manual, sem exclusão física.';
CREATE UNIQUE INDEX idx_product_groups_name ON product_groups (lower(name));
CREATE INDEX idx_product_groups_active ON product_groups (active);
CREATE TRIGGER trg_product_groups_updated_at BEFORE UPDATE ON product_groups
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE product_subgroups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES product_groups(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id)
);
COMMENT ON TABLE product_subgroups IS
'Catálogo de Subgrupos de produto — antes TEXT livre em products.product_subgroup. Sempre pertence a um grupo (group_id NOT NULL, ON DELETE RESTRICT). Nome único case-insensitivo por grupo, não globalmente: o mesmo nome pode existir em grupos diferentes.';
CREATE UNIQUE INDEX idx_product_subgroups_group_name ON product_subgroups (group_id, lower(name));
CREATE INDEX idx_product_subgroups_active ON product_subgroups (active);
CREATE INDEX idx_product_subgroups_group ON product_subgroups (group_id);
CREATE TRIGGER trg_product_subgroups_updated_at BEFORE UPDATE ON product_subgroups
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE brands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id)
);
COMMENT ON TABLE brands IS
'Catálogo de Marcas — antes TEXT livre em products.brand. Cadastro manual, sem exclusão física.';
CREATE UNIQUE INDEX idx_brands_name ON brands (lower(name));
CREATE INDEX idx_brands_active ON brands (active);
CREATE TRIGGER trg_brands_updated_at BEFORE UPDATE ON brands
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id)
);
COMMENT ON TABLE units IS
'Catálogo de Unidades de medida — antes TEXT livre em products.unit (default legado ''unidade''). Cadastro manual, sem exclusão física.';
CREATE UNIQUE INDEX idx_units_name ON units (lower(name));
CREATE INDEX idx_units_active ON units (active);
CREATE TRIGGER trg_units_updated_at BEFORE UPDATE ON units
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
INSERT INTO units (name) VALUES ('Unidade') ON CONFLICT (lower(name)) DO NOTHING;
-- =====================================================================
-- products: swap the 5 free-TEXT columns for FK columns into the
-- catalogs above. Nullable at first on purpose — tightened to NOT NULL
-- (product_type_id/unit_id only) after the backfill below.
-- =====================================================================
ALTER TABLE products
ADD COLUMN product_type_id UUID REFERENCES product_types(id),
ADD COLUMN product_group_id UUID REFERENCES product_groups(id) ON DELETE RESTRICT,
ADD COLUMN product_subgroup_id UUID REFERENCES product_subgroups(id) ON DELETE RESTRICT,
ADD COLUMN brand_id UUID REFERENCES brands(id) ON DELETE RESTRICT,
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
INSERT INTO product_types (name)
SELECT DISTINCT product_type FROM products
WHERE product_type IS NOT NULL AND product_type <> ''
ON CONFLICT DO NOTHING;
INSERT INTO brands (name)
SELECT DISTINCT brand FROM products
WHERE brand IS NOT NULL AND brand <> ''
ON CONFLICT DO NOTHING;
INSERT INTO units (name)
SELECT DISTINCT unit FROM products
WHERE unit IS NOT NULL AND unit <> ''
ON CONFLICT DO NOTHING;
-- Best-effort only — see migration header for why group/subgroup
-- relationship isn't (and can't be) reconstructed from legacy data.
INSERT INTO product_groups (name)
SELECT DISTINCT product_group FROM products
WHERE product_group IS NOT NULL AND product_group <> ''
ON CONFLICT DO NOTHING;
UPDATE products SET product_type_id = pt.id
FROM product_types pt WHERE lower(pt.name) = lower(products.product_type);
UPDATE products SET brand_id = b.id
FROM brands b WHERE lower(b.name) = lower(products.brand);
UPDATE products SET unit_id = u.id
FROM units u WHERE lower(u.name) = lower(products.unit);
UPDATE products SET product_group_id = pg.id
FROM product_groups pg WHERE lower(pg.name) = lower(products.product_group);
-- product_subgroup_id intentionally left NULL here: no legacy data
-- ever populated products.product_subgroup with a resolvable group,
-- so there is nothing safe to backfill (see migration header).
ALTER TABLE products
DROP COLUMN product_type,
DROP COLUMN product_group,
DROP COLUMN product_subgroup,
DROP COLUMN brand,
DROP COLUMN unit;
ALTER TABLE products
ALTER COLUMN product_type_id SET NOT NULL,
ALTER COLUMN unit_id SET NOT NULL;
CREATE INDEX idx_products_product_type_id ON products (product_type_id);
CREATE INDEX idx_products_product_group_id ON products (product_group_id);
CREATE INDEX idx_products_product_subgroup_id ON products (product_subgroup_id);
CREATE INDEX idx_products_brand_id ON products (brand_id);
CREATE INDEX idx_products_unit_id ON products (unit_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
ALTER TABLE products
ADD COLUMN product_type TEXT,
ADD COLUMN product_group TEXT,
ADD COLUMN product_subgroup TEXT,
ADD COLUMN brand TEXT,
ADD COLUMN unit TEXT;
UPDATE products SET product_type = pt.name
FROM product_types pt WHERE pt.id = products.product_type_id;
UPDATE products SET product_group = pg.name
FROM product_groups pg WHERE pg.id = products.product_group_id;
UPDATE products SET product_subgroup = psg.name
FROM product_subgroups psg WHERE psg.id = products.product_subgroup_id;
UPDATE products SET brand = b.name
FROM brands b WHERE b.id = products.brand_id;
UPDATE products SET unit = u.name
FROM units u WHERE u.id = products.unit_id;
UPDATE products SET unit = 'unidade' WHERE unit IS NULL;
UPDATE products SET product_type = 'service' WHERE product_type IS NULL;
ALTER TABLE products
ALTER COLUMN product_type SET NOT NULL,
ALTER COLUMN unit SET NOT NULL,
ALTER COLUMN unit SET DEFAULT 'unidade';
DROP INDEX IF EXISTS idx_products_product_type_id;
DROP INDEX IF EXISTS idx_products_product_group_id;
DROP INDEX IF EXISTS idx_products_product_subgroup_id;
DROP INDEX IF EXISTS idx_products_brand_id;
DROP INDEX IF EXISTS idx_products_unit_id;
ALTER TABLE products
DROP COLUMN product_type_id,
DROP COLUMN product_group_id,
DROP COLUMN product_subgroup_id,
DROP COLUMN brand_id,
DROP COLUMN unit_id;
DROP TABLE IF EXISTS product_subgroups;
DROP TABLE IF EXISTS product_groups;
DROP TABLE IF EXISTS product_types;
DROP TABLE IF EXISTS brands;
DROP TABLE IF EXISTS units;
`);
};

View File

@@ -6,6 +6,7 @@ export * as sessionsRepo from "./repositories/sessions.js";
export * as auditRepo from "./repositories/audit.js"; export * as auditRepo from "./repositories/audit.js";
export * as companiesRepo from "./repositories/companies.js"; export * as companiesRepo from "./repositories/companies.js";
export * as productsRepo from "./repositories/products.js"; export * as productsRepo from "./repositories/products.js";
export * as productCatalogsRepo from "./repositories/product-catalogs.js";
export * as resellersRepo from "./repositories/resellers.js"; export * as resellersRepo from "./repositories/resellers.js";
export * as quotesRepo from "./repositories/quotes.js"; export * as quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js"; export * as contractsRepo from "./repositories/contracts.js";
@@ -34,6 +35,26 @@ export {
export type * from "./repositories/types.js"; export type * from "./repositories/types.js";
export type { Company } from "./repositories/companies.js"; export type { Company } from "./repositories/companies.js";
export type { Product, CreateProductInput, UpdateProductInput } from "./repositories/products.js"; export type { Product, CreateProductInput, UpdateProductInput } from "./repositories/products.js";
export type {
SimpleCatalogRow,
CreateSimpleCatalogEntryInput,
UpdateSimpleCatalogEntryInput,
ProductType,
CreateProductTypeInput,
UpdateProductTypeInput,
ProductGroup,
CreateProductGroupInput,
UpdateProductGroupInput,
ProductSubgroup,
CreateProductSubgroupInput,
UpdateProductSubgroupInput,
Brand,
CreateBrandInput,
UpdateBrandInput,
Unit,
CreateUnitInput,
UpdateUnitInput,
} from "./repositories/product-catalogs.js";
export type { Reseller, CreateResellerInput, UpdateResellerInput } from "./repositories/resellers.js"; export type { Reseller, CreateResellerInput, UpdateResellerInput } from "./repositories/resellers.js";
export type { export type {
Lead, Lead,

View File

@@ -0,0 +1,219 @@
import { query } from "../pool.js";
/**
* Products bounded context — Tipo/Grupo/Subgrupo/Marca/Unidade catalogs
* (1789694035000_product-catalogs.cjs). Same "simple catalog" shape as
* fiscal_ncm/fiscal_cfop (see repositories/fiscal.ts) minus the sync-log
* columns: these five catalogs are always manually curated, never
* imported. No physical delete anywhere here — `active=false` is the
* "apagar", same convention as the fiscal catalogs.
*/
export interface SimpleCatalogRow {
id: string;
name: string;
active: boolean;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
}
export interface CreateSimpleCatalogEntryInput {
name: string;
active?: boolean;
createdBy?: string | null;
}
export interface UpdateSimpleCatalogEntryInput {
name?: string;
active?: boolean;
updatedBy?: string | null;
}
function buildCreateQuery(table: string) {
return `INSERT INTO ${table} (name, active, created_by, updated_by)
VALUES ($1, COALESCE($2, true), $3, $3)
RETURNING *`;
}
function buildUpdateQuery(table: string) {
return `UPDATE ${table} SET
name = COALESCE($2, name),
active = COALESCE($3, active),
updated_by = COALESCE($4, updated_by)
WHERE id = $1
RETURNING *`;
}
async function listSimpleCatalog(table: string, includeInactive: boolean): Promise<SimpleCatalogRow[]> {
const { rows } = await query<SimpleCatalogRow>(
includeInactive
? `SELECT * FROM ${table} ORDER BY name ASC`
: `SELECT * FROM ${table} WHERE active = true ORDER BY name ASC`,
);
return rows;
}
async function getSimpleCatalogById(table: string, id: string): Promise<SimpleCatalogRow | null> {
const { rows } = await query<SimpleCatalogRow>(`SELECT * FROM ${table} WHERE id = $1`, [id]);
return rows[0] ?? null;
}
async function createSimpleCatalogEntry(
table: string,
input: CreateSimpleCatalogEntryInput,
): Promise<SimpleCatalogRow> {
const { rows } = await query<SimpleCatalogRow>(buildCreateQuery(table), [
input.name,
input.active ?? null,
input.createdBy ?? null,
]);
const row = rows[0];
if (!row) throw new Error(`failed to create ${table} row`);
return row;
}
async function updateSimpleCatalogEntry(
table: string,
id: string,
input: UpdateSimpleCatalogEntryInput,
): Promise<SimpleCatalogRow | null> {
const { rows } = await query<SimpleCatalogRow>(buildUpdateQuery(table), [
id,
input.name ?? null,
input.active ?? null,
input.updatedBy ?? null,
]);
return rows[0] ?? null;
}
// ===========================================================================
// product_types
// ===========================================================================
export type ProductType = SimpleCatalogRow;
export type CreateProductTypeInput = CreateSimpleCatalogEntryInput;
export type UpdateProductTypeInput = UpdateSimpleCatalogEntryInput;
export const listProductTypes = (includeInactive = false) =>
listSimpleCatalog("product_types", includeInactive);
export const getProductTypeById = (id: string) => getSimpleCatalogById("product_types", id);
export const createProductType = (input: CreateProductTypeInput) =>
createSimpleCatalogEntry("product_types", input);
export const updateProductType = (id: string, input: UpdateProductTypeInput) =>
updateSimpleCatalogEntry("product_types", id, input);
// ===========================================================================
// product_groups
// ===========================================================================
export type ProductGroup = SimpleCatalogRow;
export type CreateProductGroupInput = CreateSimpleCatalogEntryInput;
export type UpdateProductGroupInput = UpdateSimpleCatalogEntryInput;
export const listProductGroups = (includeInactive = false) =>
listSimpleCatalog("product_groups", includeInactive);
export const getProductGroupById = (id: string) => getSimpleCatalogById("product_groups", id);
export const createProductGroup = (input: CreateProductGroupInput) =>
createSimpleCatalogEntry("product_groups", input);
export const updateProductGroup = (id: string, input: UpdateProductGroupInput) =>
updateSimpleCatalogEntry("product_groups", id, input);
// ===========================================================================
// product_subgroups — always scoped to a group_id
// ===========================================================================
export interface ProductSubgroup extends SimpleCatalogRow {
group_id: string;
}
export interface CreateProductSubgroupInput extends CreateSimpleCatalogEntryInput {
groupId: string;
}
export type UpdateProductSubgroupInput = UpdateSimpleCatalogEntryInput;
export async function listProductSubgroups(
groupId?: string,
includeInactive = false,
): Promise<ProductSubgroup[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (groupId) {
params.push(groupId);
conditions.push(`group_id = $${params.length}`);
}
if (!includeInactive) {
conditions.push("active = true");
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<ProductSubgroup>(
`SELECT * FROM product_subgroups ${where} ORDER BY name ASC`,
params,
);
return rows;
}
export async function getProductSubgroupById(id: string): Promise<ProductSubgroup | null> {
const { rows } = await query<ProductSubgroup>("SELECT * FROM product_subgroups WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createProductSubgroup(
input: CreateProductSubgroupInput,
): Promise<ProductSubgroup> {
const { rows } = await query<ProductSubgroup>(
`INSERT INTO product_subgroups (group_id, name, active, created_by, updated_by)
VALUES ($1, $2, COALESCE($3, true), $4, $4)
RETURNING *`,
[input.groupId, input.name, input.active ?? null, input.createdBy ?? null],
);
const row = rows[0];
if (!row) throw new Error("failed to create product_subgroups row");
return row;
}
export async function updateProductSubgroup(
id: string,
input: UpdateProductSubgroupInput,
): Promise<ProductSubgroup | null> {
const { rows } = await query<ProductSubgroup>(
`UPDATE product_subgroups SET
name = COALESCE($2, name),
active = COALESCE($3, active),
updated_by = COALESCE($4, updated_by)
WHERE id = $1
RETURNING *`,
[id, input.name ?? null, input.active ?? null, input.updatedBy ?? null],
);
return rows[0] ?? null;
}
// ===========================================================================
// brands
// ===========================================================================
export type Brand = SimpleCatalogRow;
export type CreateBrandInput = CreateSimpleCatalogEntryInput;
export type UpdateBrandInput = UpdateSimpleCatalogEntryInput;
export const listBrands = (includeInactive = false) => listSimpleCatalog("brands", includeInactive);
export const getBrandById = (id: string) => getSimpleCatalogById("brands", id);
export const createBrand = (input: CreateBrandInput) => createSimpleCatalogEntry("brands", input);
export const updateBrand = (id: string, input: UpdateBrandInput) =>
updateSimpleCatalogEntry("brands", id, input);
// ===========================================================================
// units
// ===========================================================================
export type Unit = SimpleCatalogRow;
export type CreateUnitInput = CreateSimpleCatalogEntryInput;
export type UpdateUnitInput = UpdateSimpleCatalogEntryInput;
export const listUnits = (includeInactive = false) => listSimpleCatalog("units", includeInactive);
export const getUnitById = (id: string) => getSimpleCatalogById("units", id);
export const createUnit = (input: CreateUnitInput) => createSimpleCatalogEntry("units", input);
export const updateUnit = (id: string, input: UpdateUnitInput) =>
updateSimpleCatalogEntry("units", id, input);

View File

@@ -1,6 +1,12 @@
import { query } from "../pool.js"; import { query } from "../pool.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared"; import type { ServiceType, TariffRates } from "@eden/domain-shared";
/**
* `*_name` columns are resolved via JOIN against the product_types/
* product_groups/product_subgroups/brands/units catalogs
* (1789694035000_product-catalogs.cjs) — never re-fetched by ID
* separately, so a list/detail response is always self-contained.
*/
export interface Product { export interface Product {
id: string; id: string;
created_at: Date; created_at: Date;
@@ -9,11 +15,16 @@ export interface Product {
product_code: number; product_code: number;
name: string; name: string;
description: string | null; description: string | null;
product_type: string; product_type_id: string;
product_group: string | null; product_type_name: string;
product_subgroup: string | null; product_group_id: string | null;
brand: string | null; product_group_name: string | null;
unit: string; 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; active: boolean;
discontinued: boolean; discontinued: boolean;
tracks_stock: boolean; tracks_stock: boolean;
@@ -33,28 +44,44 @@ export interface Product {
has_ldi: boolean; has_ldi: boolean;
} }
const SELECT_PRODUCT = `
SELECT
p.*,
pt.name AS product_type_name,
pg.name AS product_group_name,
psg.name AS product_subgroup_name,
b.name AS brand_name,
u.name AS unit_name
FROM products p
JOIN product_types pt ON pt.id = p.product_type_id
LEFT JOIN product_groups pg ON pg.id = p.product_group_id
LEFT JOIN product_subgroups psg ON psg.id = p.product_subgroup_id
LEFT JOIN brands b ON b.id = p.brand_id
JOIN units u ON u.id = p.unit_id
`;
export async function listProducts(includeInactive = false): Promise<Product[]> { export async function listProducts(includeInactive = false): Promise<Product[]> {
const { rows } = await query<Product>( const { rows } = await query<Product>(
includeInactive includeInactive
? "SELECT * FROM products ORDER BY created_at ASC" ? `${SELECT_PRODUCT} ORDER BY p.created_at ASC`
: "SELECT * FROM products WHERE active = true ORDER BY created_at ASC", : `${SELECT_PRODUCT} WHERE p.active = true ORDER BY p.created_at ASC`,
); );
return rows; return rows;
} }
export async function getProductById(id: string): Promise<Product | null> { export async function getProductById(id: string): Promise<Product | null> {
const { rows } = await query<Product>("SELECT * FROM products WHERE id = $1", [id]); const { rows } = await query<Product>(`${SELECT_PRODUCT} WHERE p.id = $1`, [id]);
return rows[0] ?? null; return rows[0] ?? null;
} }
export interface CreateProductInput { export interface CreateProductInput {
name: string; name: string;
description?: string | null; description?: string | null;
productType: string; productTypeId: string;
productGroup?: string | null; productGroupId?: string | null;
productSubgroup?: string | null; productSubgroupId?: string | null;
brand?: string | null; brandId?: string | null;
unit?: string; unitId: string;
active?: boolean; active?: boolean;
discontinued?: boolean; discontinued?: boolean;
tracksStock?: boolean; tracksStock?: boolean;
@@ -76,27 +103,27 @@ export interface CreateProductInput {
} }
export async function createProduct(input: CreateProductInput): Promise<Product> { export async function createProduct(input: CreateProductInput): Promise<Product> {
const { rows } = await query<Product>( const { rows } = await query<{ id: string }>(
`INSERT INTO products ( `INSERT INTO products (
name, description, product_type, product_group, product_subgroup, brand, unit, name, description, product_type_id, product_group_id, product_subgroup_id, brand_id, unit_id,
active, discontinued, tracks_stock, tracks_serial, tracks_asset_tag, tracks_mac, active, discontinued, tracks_stock, tracks_serial, tracks_asset_tag, tracks_mac,
price_0, price_12, price_24, price_36, price_48, impl_unit_price, price_0, price_12, price_24, price_36, price_48, impl_unit_price,
service_type, metered, minutes_allowance, tariff_rates, has_ldi, created_by service_type, metered, minutes_allowance, tariff_rates, has_ldi, created_by
) VALUES ( ) VALUES (
$1,$2,$3,$4,$5,$6,COALESCE($7,'unidade'), $1,$2,$3,$4,$5,$6,$7,
COALESCE($8,true),COALESCE($9,false),COALESCE($10,false),COALESCE($11,false),COALESCE($12,false),COALESCE($13,false), COALESCE($8,true),COALESCE($9,false),COALESCE($10,false),COALESCE($11,false),COALESCE($12,false),COALESCE($13,false),
$14,$15,$16,$17,$18,COALESCE($19,0), $14,$15,$16,$17,$18,COALESCE($19,0),
COALESCE($20,'STFC'),COALESCE($21,false),$22,COALESCE($23,'{}'::jsonb),COALESCE($24,false),$25 COALESCE($20,'STFC'),COALESCE($21,false),$22,COALESCE($23,'{}'::jsonb),COALESCE($24,false),$25
) )
RETURNING *`, RETURNING id`,
[ [
input.name, input.name,
input.description ?? null, input.description ?? null,
input.productType, input.productTypeId,
input.productGroup ?? null, input.productGroupId ?? null,
input.productSubgroup ?? null, input.productSubgroupId ?? null,
input.brand ?? null, input.brandId ?? null,
input.unit ?? null, input.unitId,
input.active ?? null, input.active ?? null,
input.discontinued ?? null, input.discontinued ?? null,
input.tracksStock ?? null, input.tracksStock ?? null,
@@ -117,7 +144,9 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
input.createdBy ?? null, input.createdBy ?? null,
], ],
); );
const product = rows[0]; const id = rows[0]?.id;
if (!id) throw new Error("failed to create product");
const product = await getProductById(id);
if (!product) throw new Error("failed to create product"); if (!product) throw new Error("failed to create product");
return product; return product;
} }
@@ -125,15 +154,15 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
export type UpdateProductInput = Partial<Omit<CreateProductInput, "createdBy">>; export type UpdateProductInput = Partial<Omit<CreateProductInput, "createdBy">>;
export async function updateProduct(id: string, input: UpdateProductInput): Promise<Product | null> { export async function updateProduct(id: string, input: UpdateProductInput): Promise<Product | null> {
const { rows } = await query<Product>( const { rows } = await query<{ id: string }>(
`UPDATE products SET `UPDATE products SET
name = COALESCE($2, name), name = COALESCE($2, name),
description = COALESCE($3, description), description = COALESCE($3, description),
product_type = COALESCE($4, product_type), product_type_id = COALESCE($4, product_type_id),
product_group = COALESCE($5, product_group), product_group_id = COALESCE($5, product_group_id),
product_subgroup = COALESCE($6, product_subgroup), product_subgroup_id = COALESCE($6, product_subgroup_id),
brand = COALESCE($7, brand), brand_id = COALESCE($7, brand_id),
unit = COALESCE($8, unit), unit_id = COALESCE($8, unit_id),
active = COALESCE($9, active), active = COALESCE($9, active),
discontinued = COALESCE($10, discontinued), discontinued = COALESCE($10, discontinued),
tracks_stock = COALESCE($11, tracks_stock), tracks_stock = COALESCE($11, tracks_stock),
@@ -152,16 +181,16 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
tariff_rates = COALESCE($24, tariff_rates), tariff_rates = COALESCE($24, tariff_rates),
has_ldi = COALESCE($25, has_ldi) has_ldi = COALESCE($25, has_ldi)
WHERE id = $1 WHERE id = $1
RETURNING *`, RETURNING id`,
[ [
id, id,
input.name ?? null, input.name ?? null,
input.description ?? null, input.description ?? null,
input.productType ?? null, input.productTypeId ?? null,
input.productGroup ?? null, input.productGroupId ?? null,
input.productSubgroup ?? null, input.productSubgroupId ?? null,
input.brand ?? null, input.brandId ?? null,
input.unit ?? null, input.unitId ?? null,
input.active ?? null, input.active ?? null,
input.discontinued ?? null, input.discontinued ?? null,
input.tracksStock ?? null, input.tracksStock ?? null,
@@ -181,5 +210,17 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
input.hasLdi ?? null, input.hasLdi ?? null,
], ],
); );
return rows[0] ?? null; if (!rows[0]) return null;
return getProductById(id);
}
/**
* `updateProduct`'s COALESCE-based SET can't express "set to NULL" for a
* nullable column — a null parameter reads as "unchanged", same as
* omitting the field. An explicit subgroup removal needs its own
* statement; called by ProductsService right after `updateProduct` when
* `productSubgroupId === null` was explicitly requested.
*/
export async function clearProductSubgroup(id: string): Promise<void> {
await query(`UPDATE products SET product_subgroup_id = NULL WHERE id = $1`, [id]);
} }

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import type { ComponentType, ReactNode } from "react"; import type { ComponentType, ReactNode } from "react";
import { cn } from "../utils/cn.js"; import { cn } from "../utils/cn.js";
@@ -7,6 +8,13 @@ export interface AppShellNavItem {
href: string; href: string;
icon?: ReactNode; icon?: ReactNode;
active?: boolean; active?: boolean;
/**
* When present (even if empty), this item renders as an expandable
* accordion group instead of a direct link — `href` is then only a
* placeholder (typically duplicated by the first child) and is never
* navigated to directly. Only one level deep is supported.
*/
children?: AppShellNavItem[];
} }
interface NavLinkRenderProps { interface NavLinkRenderProps {
@@ -41,6 +49,74 @@ function DefaultLink({ href, className, children }: NavLinkRenderProps) {
); );
} }
const NAV_LINK_CLASSES = "flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150";
const NAV_LINK_ACTIVE_CLASSES = "bg-dark text-white";
const NAV_LINK_INACTIVE_CLASSES = "text-gray-900 hover:bg-light-700 hover:text-primary";
/**
* A nav item with `children` — an accordion button that expands/collapses a
* sublist. `item.active` is expected to already reflect "this item or one of
* its descendants matches the current route" (computed by the consuming
* app, which owns routing) — used both to highlight the button while closed
* and to auto-open the sublist so the active child is never hidden inside a
* collapsed group.
*/
function NavGroup({
item,
LinkComponent,
}: {
item: AppShellNavItem;
LinkComponent: ComponentType<NavLinkRenderProps>;
}) {
const [open, setOpen] = useState(Boolean(item.active));
useEffect(() => {
if (item.active) setOpen(true);
}, [item.active]);
return (
<div>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
className={cn("w-full", NAV_LINK_CLASSES, item.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
>
{item.icon}
<span className="flex-1 text-left">{item.label}</span>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
className={cn("size-3.5 shrink-0 transition-transform duration-150", open ? "rotate-180" : "")}
>
<path
d="M5 7.5L10 12.5L15 7.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open ? (
<div className="ms-3 mt-1 flex flex-col gap-1 border-s border-border-color ps-3">
{(item.children ?? []).map((child) => (
<LinkComponent
key={child.key}
href={child.href}
className={cn(NAV_LINK_CLASSES, child.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
>
{child.icon}
<span>{child.label}</span>
</LinkComponent>
))}
</div>
) : null}
</div>
);
}
/** /**
* Visually inspired by the DreamsERP theme's sidebar/topbar * Visually inspired by the DreamsERP theme's sidebar/topbar
* (react/src/components/common-sidebar, common-header) — sidebar with * (react/src/components/common-sidebar, common-header) — sidebar with
@@ -65,21 +141,20 @@ export function AppShell({
<span className="text-sm font-bold text-title">{appName}</span> <span className="text-sm font-bold text-title">{appName}</span>
</div> </div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3" aria-label="Navegação principal"> <nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3" aria-label="Navegação principal">
{navItems.map((item) => ( {navItems.map((item) =>
<LinkComponent item.children ? (
key={item.key} <NavGroup key={item.key} item={item} LinkComponent={LinkComponent} />
href={item.href} ) : (
className={cn( <LinkComponent
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150", key={item.key}
item.active href={item.href}
? "bg-dark text-white" className={cn(NAV_LINK_CLASSES, item.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
: "text-gray-900 hover:bg-light-700 hover:text-primary", >
)} {item.icon}
> <span>{item.label}</span>
{item.icon} </LinkComponent>
<span>{item.label}</span> ),
</LinkComponent> )}
))}
</nav> </nav>
</aside> </aside>