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