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

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 { CreateProductDto } from "./dto/create-product.dto.js";
import { UpdateProductDto } from "./dto/update-product.dto.js";
import { CreateProductTypeDto } from "./dto/create-product-type.dto.js";
import { UpdateProductTypeDto } from "./dto/update-product-type.dto.js";
import { CreateProductGroupDto } from "./dto/create-product-group.dto.js";
import { UpdateProductGroupDto } from "./dto/update-product-group.dto.js";
import { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js";
import { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js";
import { CreateBrandDto } from "./dto/create-brand.dto.js";
import { UpdateBrandDto } from "./dto/update-brand.dto.js";
import { CreateUnitDto } from "./dto/create-unit.dto.js";
import { UpdateUnitDto } from "./dto/update-unit.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
@@ -16,6 +26,12 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
* for companies. Write requires products:edit. Backend always
* recalculates/validates: money fields are format-checked (non-negative)
* in the DTO but no pricing logic runs here — that's Quotes, a later phase.
*
* Tipo/Grupo/Subgrupo/Marca/Unidade sub-catalogs (product_types/
* product_groups/product_subgroups/brands/units) share this controller,
* same style as FiscalController grouping fiscal_ncm/fiscal_cfop/etc. — no
* physical delete for any of them in this MVP, `active=false` is the
* "apagar".
*/
@ApiTags("products")
@ApiBearerAuth()
@@ -29,6 +45,126 @@ export class ProductsController {
return this.products.list(includeInactive === "true");
}
// -- product_types --------------------------------------------------------
@Get("types")
listTypes(@Query("includeInactive") includeInactive?: string) {
return this.products.listProductTypes(includeInactive === "true");
}
@Post("types")
@RequirePermission("products", "edit")
createType(@Body() dto: CreateProductTypeDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createProductType(dto, user);
}
@Patch("types/:id")
@RequirePermission("products", "edit")
updateType(
@Param("id") id: string,
@Body() dto: UpdateProductTypeDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateProductType(id, dto, user);
}
// -- product_groups ---------------------------------------------------------
@Get("groups")
listGroups(@Query("includeInactive") includeInactive?: string) {
return this.products.listProductGroups(includeInactive === "true");
}
@Post("groups")
@RequirePermission("products", "edit")
createGroup(@Body() dto: CreateProductGroupDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createProductGroup(dto, user);
}
@Patch("groups/:id")
@RequirePermission("products", "edit")
updateGroup(
@Param("id") id: string,
@Body() dto: UpdateProductGroupDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateProductGroup(id, dto, user);
}
// -- product_subgroups --------------------------------------------------------
@Get("subgroups")
listSubgroups(
@Query("groupId") groupId?: string,
@Query("includeInactive") includeInactive?: string,
) {
return this.products.listProductSubgroups(groupId, includeInactive === "true");
}
@Post("subgroups")
@RequirePermission("products", "edit")
createSubgroup(@Body() dto: CreateProductSubgroupDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createProductSubgroup(dto, user);
}
@Patch("subgroups/:id")
@RequirePermission("products", "edit")
updateSubgroup(
@Param("id") id: string,
@Body() dto: UpdateProductSubgroupDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateProductSubgroup(id, dto, user);
}
// -- brands -----------------------------------------------------------------
@Get("brands")
listBrands(@Query("includeInactive") includeInactive?: string) {
return this.products.listBrands(includeInactive === "true");
}
@Post("brands")
@RequirePermission("products", "edit")
createBrand(@Body() dto: CreateBrandDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createBrand(dto, user);
}
@Patch("brands/:id")
@RequirePermission("products", "edit")
updateBrand(
@Param("id") id: string,
@Body() dto: UpdateBrandDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateBrand(id, dto, user);
}
// -- units --------------------------------------------------------------------
@Get("units")
listUnits(@Query("includeInactive") includeInactive?: string) {
return this.products.listUnits(includeInactive === "true");
}
@Post("units")
@RequirePermission("products", "edit")
createUnit(@Body() dto: CreateUnitDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.products.createUnit(dto, user);
}
@Patch("units/:id")
@RequirePermission("products", "edit")
updateUnit(
@Param("id") id: string,
@Body() dto: UpdateUnitDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.products.updateUnit(id, dto, user);
}
// -- products (:id must come after every static sub-route above) --------------
@Get(":id")
get(@Param("id") id: string) {
return this.products.get(id);

View File

@@ -1,8 +1,29 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { auditRepo, productsRepo, type Product } from "@eden/database";
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
productsRepo,
productCatalogsRepo,
type Product,
type ProductType,
type ProductGroup,
type ProductSubgroup,
type Brand,
type Unit,
} from "@eden/database";
import { emptyTariffRates, type TariffRates } from "@eden/domain-shared";
import type { CreateProductDto } from "./dto/create-product.dto.js";
import type { UpdateProductDto } from "./dto/update-product.dto.js";
import type { CreateProductTypeDto } from "./dto/create-product-type.dto.js";
import type { UpdateProductTypeDto } from "./dto/update-product-type.dto.js";
import type { CreateProductGroupDto } from "./dto/create-product-group.dto.js";
import type { UpdateProductGroupDto } from "./dto/update-product-group.dto.js";
import type { CreateProductSubgroupDto } from "./dto/create-product-subgroup.dto.js";
import type { UpdateProductSubgroupDto } from "./dto/update-product-subgroup.dto.js";
import type { CreateBrandDto } from "./dto/create-brand.dto.js";
import type { UpdateBrandDto } from "./dto/update-brand.dto.js";
import type { CreateUnitDto } from "./dto/create-unit.dto.js";
import type { UpdateUnitDto } from "./dto/update-unit.dto.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/** DTO fields are individually optional (partial override); persisted tariff_rates always carries all 6 keys — mirrors the legacy's normalizeTariffRates(), never a partial object at rest. */
function normalizeTariffRates(partial: Partial<TariffRates> | undefined): TariffRates | undefined {
@@ -10,6 +31,10 @@ function normalizeTariffRates(partial: Partial<TariffRates> | undefined): Tariff
return { ...emptyTariffRates(), ...partial };
}
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505";
}
@Injectable()
export class ProductsService {
list(includeInactive: boolean): Promise<Product[]> {
@@ -23,14 +48,17 @@ export class ProductsService {
}
async create(dto: CreateProductDto, actorId: string): Promise<Product> {
await this.assertCatalogReferencesValid(dto);
await this.assertSubgroupConsistent(dto, null);
const product = await productsRepo.createProduct({
name: dto.name,
description: dto.description,
productType: dto.productType,
productGroup: dto.productGroup,
productSubgroup: dto.productSubgroup,
brand: dto.brand,
unit: dto.unit,
productTypeId: dto.productTypeId,
productGroupId: dto.productGroupId,
productSubgroupId: dto.productSubgroupId,
brandId: dto.brandId,
unitId: dto.unitId,
active: dto.active,
discontinued: dto.discontinued,
tracksStock: dto.tracksStock,
@@ -63,16 +91,18 @@ export class ProductsService {
}
async update(id: string, dto: UpdateProductDto, actorId: string): Promise<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,
description: dto.description,
productType: dto.productType,
productGroup: dto.productGroup,
productSubgroup: dto.productSubgroup,
brand: dto.brand,
unit: dto.unit,
productTypeId: dto.productTypeId,
productGroupId: dto.productGroupId,
productSubgroupId: dto.productSubgroupId,
brandId: dto.brandId,
unitId: dto.unitId,
active: dto.active,
discontinued: dto.discontinued,
tracksStock: dto.tracksStock,
@@ -93,6 +123,15 @@ export class ProductsService {
});
if (!product) throw new NotFoundException("Produto não encontrado");
// `productSubgroupId: null` is an explicit clear — the COALESCE-based
// update above can't express "set to NULL" vs. "leave unchanged"
// (both collapse to the same SQL NULL parameter), so it's a deliberate
// no-op there and corrected here with a dedicated statement.
if (dto.productSubgroupId === null) {
await productsRepo.clearProductSubgroup(id);
product = (await productsRepo.getProductById(id)) ?? product;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
@@ -104,4 +143,358 @@ export class ProductsService {
return product;
}
/**
* Validates the simple (existence-only) catalog references on
* create/update: type/unit/group/brand must exist. Subgroup consistency
* is handled separately by `assertSubgroupConsistent` — it needs the
* product's current state on update, which this method doesn't have.
*/
private async assertCatalogReferencesValid(
dto: Partial<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 { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaName, type AuthedTokens } from "./setup.js";
/**
* `renderPdf` is mocked (real call path through quotes.service.ts still
@@ -22,6 +22,8 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
let resellerId: string;
let stfcProductId: string;
let pabxProductId: string;
let productTypeId: string;
let unitId: string;
beforeAll(async () => {
app = await createTestApp();
@@ -34,12 +36,27 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
.expect(201);
resellerId = reseller.body.id;
const productType = await agent(app)
.post("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Tipo ANATEL") })
.expect(201);
productTypeId = productType.body.id;
const unit = await agent(app)
.post("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Unidade ANATEL") })
.expect(201);
unitId = unit.body.id;
const stfcProduct = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: "qa-test- Linha STFC",
productType: "service",
productTypeId,
unitId,
price0: 200,
price12: 150,
serviceType: "STFC",
@@ -63,7 +80,7 @@ describe("quotes — anexo de conformidade ANATEL/RGC", () => {
const pabxProduct = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Ramal PABX", productType: "service", price0: 50, serviceType: "Serviço PABX" })
.send({ name: "qa-test- Ramal PABX", productTypeId, unitId, price0: 50, serviceType: "Serviço PABX" })
.expect(201);
pabxProductId = pabxProduct.body.id;
});

View File

@@ -9,6 +9,7 @@ import {
loginSuperAdmin,
qaEmail,
qaKey,
qaName,
type AuthedTokens,
} from "./setup.js";
@@ -45,10 +46,27 @@ describe("contracts — edit lock past 'draft'", () => {
.expect(201);
resellerId = reseller.body.id;
const productType = await agent(app)
.post("/api/v1/products/types")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Tipo Contract Lock") })
.expect(201);
const unit = await agent(app)
.post("/api/v1/products/units")
.set(...bearer(admin.accessToken))
.send({ name: qaName("Unidade Contract Lock") })
.expect(201);
const product = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Produto Contract Lock", productType: "service", price0: 100 })
.send({
name: "qa-test- Produto Contract Lock",
productTypeId: productType.body.id,
unitId: unit.body.id,
price0: 100,
})
.expect(201);
productId = product.body.id;

View File

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

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).
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 { ProductsListPage } from "./routes/products/ProductsListPage.js";
import { ProductFormPage } from "./routes/products/ProductFormPage.js";
import { ProductTypesListPage } from "./routes/products/catalogs/ProductTypesListPage.js";
import { ProductGroupsListPage } from "./routes/products/catalogs/ProductGroupsListPage.js";
import { BrandsListPage } from "./routes/products/catalogs/BrandsListPage.js";
import { UnitsListPage } from "./routes/products/catalogs/UnitsListPage.js";
import { ResellersListPage } from "./routes/resellers/ResellersListPage.js";
import { ResellerFormPage } from "./routes/resellers/ResellerFormPage.js";
import { LeadsListPage } from "./routes/crm/LeadsListPage.js";
@@ -82,6 +86,10 @@ export function App() {
<Route path="/products" element={<ProductsListPage />} />
<Route path="/products/new" 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/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;
name: string;
description: string | null;
product_type: string;
product_group: string | null;
product_subgroup: string | null;
brand: string | null;
unit: string;
product_type_id: string;
product_type_name: string;
product_group_id: string | null;
product_group_name: string | null;
product_subgroup_id: string | null;
product_subgroup_name: string | null;
brand_id: string | null;
brand_name: string | null;
unit_id: string;
unit_name: string;
active: boolean;
discontinued: boolean;
tracks_stock: boolean;
@@ -51,11 +56,12 @@ export interface Product {
export interface CreateProductInput {
name: string;
description?: string;
productType: string;
productGroup?: string;
productSubgroup?: string;
brand?: string;
unit?: string;
productTypeId: string;
productGroupId?: string;
/** `null` explicitly clears it on update (distinct from omitting, which leaves it unchanged) — see ProductFormPage's payload construction. */
productSubgroupId?: string | null;
brandId?: string;
unitId: string;
active?: boolean;
discontinued?: boolean;
tracksStock?: boolean;

View File

@@ -10,28 +10,74 @@ function isNavItemActive(href: string, pathname: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
interface NavItemDef {
key: string;
label: string;
href: string;
children?: NavItemDef[];
}
/**
* Computes `active` bottom-up. Leaves are matched with `isNavItemActive`
* against their own href; when siblings overlap by prefix (e.g. "Itens"
* href=`/products` is a `/`-prefix of "Tipos" href=`/products/types`), only
* the most specific (longest href) match is marked active, so navigating to
* `/products/types` never also lights up "Itens". A group item (has
* `children`) is active exactly when one of its children is — its own
* `href` is a placeholder (it renders as an accordion button, never a
* direct link) and is never matched on its own.
*/
function withActive(items: NavItemDef[], pathname: string): AppShellNavItem[] {
const leafMatches = items.filter((item) => !item.children && isNavItemActive(item.href, pathname));
const mostSpecific = leafMatches.reduce<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() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const navItems: AppShellNavItem[] = [
{ key: "dashboard", label: "Painel", href: "/" },
{ key: "companies", label: "Empresas", href: "/companies" },
{ key: "products", label: "Produtos", href: "/products" },
{ key: "resellers", label: "Revendas", href: "/resellers" },
{ key: "leads", label: "CRM", href: "/leads" },
{ key: "quotes", label: "Ofertas", href: "/quotes" },
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" },
{ key: "contracts", label: "Contratos", href: "/contracts" },
{ key: "documents", label: "Documentos", href: "/documents/templates" },
{ key: "signatures", label: "Assinaturas", href: "/signatures" },
{ key: "inventory", label: "Estoque", href: "/inventory/assets" },
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
{ key: "support", label: "Suporte", href: "/support/tickets" },
{ key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" },
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
const navItems = withActive(NAV_ITEM_DEFS, location.pathname);
async function handleLogout() {
await logout();

View File

@@ -2,15 +2,38 @@ import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import type { SelectOption } from "@eden/ui";
import { SERVICE_TYPES, TARIFF_TYPES } from "@eden/domain-shared";
import type { ServiceType, TariffType } from "@eden/domain-shared";
import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js";
import type { CreateProductInput } from "../../lib/api/products.js";
import {
listBrands,
listProductGroups,
listProductSubgroups,
listProductTypes,
listUnits,
} from "../../lib/api/product-catalogs.js";
import type {
BrandRow,
ProductGroupRow,
ProductSubgroupRow,
ProductTypeRow,
UnitRow,
} from "../../lib/api/product-catalogs.js";
import { ApiError } from "../../lib/api/http.js";
import { formatCurrencyBRL } from "../../lib/format.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ProductFiscalSection } from "./ProductFiscalSection.js";
/** Only `active` rows populate a dropdown, except the row currently selected on an existing product — kept visible (even if meanwhile deactivated) so editing never silently blanks the field. */
function toCatalogOptions<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. */
function centsToDisplay(cents: number): string {
return formatCurrencyBRL(cents / 100);
@@ -44,11 +67,11 @@ function CurrencyInput({
interface ProductFormState {
name: string;
description: string;
productType: string;
productGroup: string;
productSubgroup: string;
brand: string;
unit: string;
productTypeId: string;
productGroupId: string;
productSubgroupId: string;
brandId: string;
unitId: string;
active: boolean;
discontinued: boolean;
tracksStock: boolean;
@@ -78,11 +101,11 @@ function emptyTariffRatesCents(): ProductFormState["tariffRatesCents"] {
const EMPTY_FORM: ProductFormState = {
name: "",
description: "",
productType: "",
productGroup: "",
productSubgroup: "",
brand: "",
unit: "",
productTypeId: "",
productGroupId: "",
productSubgroupId: "",
brandId: "",
unitId: "",
active: true,
discontinued: false,
tracksStock: false,
@@ -128,11 +151,11 @@ export function ProductFormPage() {
setForm({
name: product.name,
description: product.description ?? "",
productType: product.product_type,
productGroup: product.product_group ?? "",
productSubgroup: product.product_subgroup ?? "",
brand: product.brand ?? "",
unit: product.unit,
productTypeId: product.product_type_id,
productGroupId: product.product_group_id ?? "",
productSubgroupId: product.product_subgroup_id ?? "",
brandId: product.brand_id ?? "",
unitId: product.unit_id,
active: product.active,
discontinued: product.discontinued,
tracksStock: product.tracks_stock,
@@ -171,6 +194,75 @@ export function ProductFormPage() {
};
}, [id, accessToken]);
const [catalogs, setCatalogs] = useState<{
types: ProductTypeRow[];
groups: ProductGroupRow[];
brands: BrandRow[];
units: UnitRow[];
} | null>(null);
const [catalogError, setCatalogError] = useState<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]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
@@ -184,11 +276,15 @@ export function ProductFormPage() {
const payload: CreateProductInput = {
name: form.name.trim(),
description: toOptional(form.description),
productType: form.productType.trim(),
productGroup: toOptional(form.productGroup),
productSubgroup: toOptional(form.productSubgroup),
brand: toOptional(form.brand),
unit: toOptional(form.unit),
productTypeId: form.productTypeId,
productGroupId: toOptional(form.productGroupId),
// `null` (not `undefined`) when empty — PATCH treats an omitted
// field as "leave unchanged" but `null` as an explicit clear
// (needed when the group changed and the old subgroup no longer
// applies, per the auto-clear effect above). Harmless for POST.
productSubgroupId: form.productSubgroupId.trim() === "" ? null : form.productSubgroupId.trim(),
brandId: toOptional(form.brandId),
unitId: form.unitId,
active: form.active,
discontinued: form.discontinued,
tracksStock: form.tracksStock,
@@ -226,6 +322,12 @@ export function ProductFormPage() {
}
}
const productTypeOptions = catalogs ? toCatalogOptions(catalogs.types, form.productTypeId) : [];
const unitOptions = catalogs ? toCatalogOptions(catalogs.units, form.unitId) : [];
const groupOptions = catalogs ? toCatalogOptions(catalogs.groups, form.productGroupId) : [];
const brandOptions = catalogs ? toCatalogOptions(catalogs.brands, form.brandId) : [];
const subgroupOptions = toCatalogOptions(subgroups, form.productSubgroupId);
return (
<div>
<PageHeader
@@ -253,6 +355,12 @@ export function ProductFormPage() {
</Alert>
) : null}
{catalogError ? (
<Alert variant="error" className="mb-4">
{catalogError}
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
@@ -264,24 +372,51 @@ export function ProductFormPage() {
<div className="sm:col-span-2">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
</div>
<Input
<Select
label="Tipo"
value={form.productType}
onChange={(e) => updateField("productType", e.target.value)}
options={productTypeOptions}
value={form.productTypeId}
onChange={(e) => updateField("productTypeId", e.target.value)}
placeholder={catalogs === null ? "Carregando…" : "Selecione um tipo"}
required
/>
<Input label="Unidade" placeholder="unidade" value={form.unit} onChange={(e) => updateField("unit", e.target.value)} />
<Input
<Select
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"
value={form.productGroup}
onChange={(e) => updateField("productGroup", e.target.value)}
options={groupOptions}
value={form.productGroupId}
onChange={(e) => updateField("productGroupId", e.target.value)}
placeholder={catalogs === null ? "Carregando…" : "Nenhum"}
/>
<Input
<Select
label="Subgrupo"
value={form.productSubgroup}
onChange={(e) => updateField("productSubgroup", e.target.value)}
options={subgroupOptions}
value={form.productSubgroupId}
onChange={(e) => updateField("productSubgroupId", e.target.value)}
disabled={!form.productGroupId}
placeholder={
!form.productGroupId
? "Selecione um grupo primeiro"
: subgroupsLoading
? "Carregando…"
: "Nenhum"
}
hint={!form.productGroupId ? "Escolha um grupo para habilitar o subgrupo." : undefined}
/>
<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">
<label className="mb-1 block text-sm font-semibold text-gray-900" htmlFor="product-description">
Descrição

View File

@@ -44,7 +44,7 @@ export function ProductsListPage() {
const columns: Array<TableColumn<Product>> = [
{ 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: "type", header: "Tipo", render: (product) => product.product_type },
{ key: "type", header: "Tipo", render: (product) => product.product_type_name },
...PRICE_COLUMNS.map(
(column): TableColumn<Product> => ({
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 };
}