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:
9
apps/api/src/products/dto/create-brand.dto.ts
Normal file
9
apps/api/src/products/dto/create-brand.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
9
apps/api/src/products/dto/create-product-group.dto.ts
Normal file
9
apps/api/src/products/dto/create-product-group.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
12
apps/api/src/products/dto/create-product-subgroup.dto.ts
Normal file
12
apps/api/src/products/dto/create-product-subgroup.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
9
apps/api/src/products/dto/create-product-type.dto.ts
Normal file
9
apps/api/src/products/dto/create-product-type.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
9
apps/api/src/products/dto/create-unit.dto.ts
Normal file
9
apps/api/src/products/dto/create-unit.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
4
apps/api/src/products/dto/update-brand.dto.ts
Normal file
4
apps/api/src/products/dto/update-brand.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
import { CreateBrandDto } from "./create-brand.dto.js";
|
||||||
|
|
||||||
|
export class UpdateBrandDto extends PartialType(CreateBrandDto) {}
|
||||||
4
apps/api/src/products/dto/update-product-group.dto.ts
Normal file
4
apps/api/src/products/dto/update-product-group.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
import { CreateProductGroupDto } from "./create-product-group.dto.js";
|
||||||
|
|
||||||
|
export class UpdateProductGroupDto extends PartialType(CreateProductGroupDto) {}
|
||||||
7
apps/api/src/products/dto/update-product-subgroup.dto.ts
Normal file
7
apps/api/src/products/dto/update-product-subgroup.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
4
apps/api/src/products/dto/update-product-type.dto.ts
Normal file
4
apps/api/src/products/dto/update-product-type.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
import { CreateProductTypeDto } from "./create-product-type.dto.js";
|
||||||
|
|
||||||
|
export class UpdateProductTypeDto extends PartialType(CreateProductTypeDto) {}
|
||||||
4
apps/api/src/products/dto/update-unit.dto.ts
Normal file
4
apps/api/src/products/dto/update-unit.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
import { CreateUnitDto } from "./create-unit.dto.js";
|
||||||
|
|
||||||
|
export class UpdateUnitDto extends PartialType(CreateUnitDto) {}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
444
apps/api/test/product-catalogs.spec.ts
Normal file
444
apps/api/test/product-catalogs.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
165
apps/core-web/src/lib/api/product-catalogs.ts
Normal file
165
apps/core-web/src/lib/api/product-catalogs.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -10,15 +10,54 @@ function isNavItemActive(href: string, pathname: string): boolean {
|
|||||||
return pathname === href || pathname.startsWith(`${href}/`);
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthenticatedLayout() {
|
interface NavItemDef {
|
||||||
const { user, logout } = useAuth();
|
key: string;
|
||||||
const navigate = useNavigate();
|
label: string;
|
||||||
const location = useLocation();
|
href: string;
|
||||||
|
children?: NavItemDef[];
|
||||||
|
}
|
||||||
|
|
||||||
const navItems: AppShellNavItem[] = [
|
/**
|
||||||
|
* 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: "dashboard", label: "Painel", href: "/" },
|
||||||
{ key: "companies", label: "Empresas", href: "/companies" },
|
{ key: "companies", label: "Empresas", href: "/companies" },
|
||||||
{ key: "products", label: "Produtos", href: "/products" },
|
{
|
||||||
|
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: "resellers", label: "Revendas", href: "/resellers" },
|
||||||
{ key: "leads", label: "CRM", href: "/leads" },
|
{ key: "leads", label: "CRM", href: "/leads" },
|
||||||
{ key: "quotes", label: "Ofertas", href: "/quotes" },
|
{ key: "quotes", label: "Ofertas", href: "/quotes" },
|
||||||
@@ -31,7 +70,14 @@ export function AuthenticatedLayout() {
|
|||||||
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
|
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
|
||||||
{ key: "support", label: "Suporte", href: "/support/tickets" },
|
{ key: "support", label: "Suporte", href: "/support/tickets" },
|
||||||
{ key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" },
|
{ key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" },
|
||||||
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
|
];
|
||||||
|
|
||||||
|
export function AuthenticatedLayout() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const navItems = withActive(NAV_ITEM_DEFS, location.pathname);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await logout();
|
await logout();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
apps/core-web/src/routes/products/catalogs/CatalogTable.tsx
Normal file
67
apps/core-web/src/routes/products/catalogs/CatalogTable.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/core-web/src/routes/products/catalogs/UnitsListPage.tsx
Normal file
37
apps/core-web/src/routes/products/catalogs/UnitsListPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
260
packages/database/migrations/1789694035000_product-catalogs.cjs
Normal file
260
packages/database/migrations/1789694035000_product-catalogs.cjs
Normal 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;
|
||||||
|
`);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
219
packages/database/src/repositories/product-catalogs.ts
Normal file
219
packages/database/src/repositories/product-catalogs.ts
Normal 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);
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
item.children ? (
|
||||||
|
<NavGroup key={item.key} item={item} LinkComponent={LinkComponent} />
|
||||||
|
) : (
|
||||||
<LinkComponent
|
<LinkComponent
|
||||||
key={item.key}
|
key={item.key}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(NAV_LINK_CLASSES, item.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
|
||||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150",
|
|
||||||
item.active
|
|
||||||
? "bg-dark text-white"
|
|
||||||
: "text-gray-900 hover:bg-light-700 hover:text-primary",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</LinkComponent>
|
</LinkComponent>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user