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

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

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

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

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

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

View File

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

View File

@@ -6,6 +6,7 @@ export * as sessionsRepo from "./repositories/sessions.js";
export * as auditRepo from "./repositories/audit.js";
export * as companiesRepo from "./repositories/companies.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 quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js";
@@ -34,6 +35,26 @@ export {
export type * from "./repositories/types.js";
export type { Company } from "./repositories/companies.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 {
Lead,

View File

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

View File

@@ -1,6 +1,12 @@
import { query } from "../pool.js";
import 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 {
id: string;
created_at: Date;
@@ -9,11 +15,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;
@@ -33,28 +44,44 @@ export interface Product {
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[]> {
const { rows } = await query<Product>(
includeInactive
? "SELECT * FROM products ORDER BY created_at ASC"
: "SELECT * FROM products WHERE active = true ORDER BY created_at ASC",
? `${SELECT_PRODUCT} ORDER BY p.created_at ASC`
: `${SELECT_PRODUCT} WHERE p.active = true ORDER BY p.created_at ASC`,
);
return rows;
}
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;
}
export interface CreateProductInput {
name: string;
description?: string | null;
productType: string;
productGroup?: string | null;
productSubgroup?: string | null;
brand?: string | null;
unit?: string;
productTypeId: string;
productGroupId?: string | null;
productSubgroupId?: string | null;
brandId?: string | null;
unitId: string;
active?: boolean;
discontinued?: boolean;
tracksStock?: boolean;
@@ -76,27 +103,27 @@ export interface CreateProductInput {
}
export async function createProduct(input: CreateProductInput): Promise<Product> {
const { rows } = await query<Product>(
const { rows } = await query<{ id: string }>(
`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,
price_0, price_12, price_24, price_36, price_48, impl_unit_price,
service_type, metered, minutes_allowance, tariff_rates, has_ldi, created_by
) 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),
$14,$15,$16,$17,$18,COALESCE($19,0),
COALESCE($20,'STFC'),COALESCE($21,false),$22,COALESCE($23,'{}'::jsonb),COALESCE($24,false),$25
)
RETURNING *`,
RETURNING id`,
[
input.name,
input.description ?? null,
input.productType,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.productTypeId,
input.productGroupId ?? null,
input.productSubgroupId ?? null,
input.brandId ?? null,
input.unitId,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
@@ -117,7 +144,9 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
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");
return product;
}
@@ -125,15 +154,15 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
export type UpdateProductInput = Partial<Omit<CreateProductInput, "createdBy">>;
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
name = COALESCE($2, name),
description = COALESCE($3, description),
product_type = COALESCE($4, product_type),
product_group = COALESCE($5, product_group),
product_subgroup = COALESCE($6, product_subgroup),
brand = COALESCE($7, brand),
unit = COALESCE($8, unit),
product_type_id = COALESCE($4, product_type_id),
product_group_id = COALESCE($5, product_group_id),
product_subgroup_id = COALESCE($6, product_subgroup_id),
brand_id = COALESCE($7, brand_id),
unit_id = COALESCE($8, unit_id),
active = COALESCE($9, active),
discontinued = COALESCE($10, discontinued),
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),
has_ldi = COALESCE($25, has_ldi)
WHERE id = $1
RETURNING *`,
RETURNING id`,
[
id,
input.name ?? null,
input.description ?? null,
input.productType ?? null,
input.productGroup ?? null,
input.productSubgroup ?? null,
input.brand ?? null,
input.unit ?? null,
input.productTypeId ?? null,
input.productGroupId ?? null,
input.productSubgroupId ?? null,
input.brandId ?? null,
input.unitId ?? null,
input.active ?? null,
input.discontinued ?? null,
input.tracksStock ?? null,
@@ -181,5 +210,17 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
input.hasLdi ?? null,
],
);
return rows[0] ?? null;
if (!rows[0]) return null;
return getProductById(id);
}
/**
* `updateProduct`'s COALESCE-based SET can't express "set to NULL" for a
* nullable column — a null parameter reads as "unchanged", same as
* omitting the field. An explicit subgroup removal needs its own
* statement; called by ProductsService right after `updateProduct` when
* `productSubgroupId === null` was explicitly requested.
*/
export async function clearProductSubgroup(id: string): Promise<void> {
await query(`UPDATE products SET product_subgroup_id = NULL WHERE id = $1`, [id]);
}

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import { cn } from "../utils/cn.js";
@@ -7,6 +8,13 @@ export interface AppShellNavItem {
href: string;
icon?: ReactNode;
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 {
@@ -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
* (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>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3" aria-label="Navegação principal">
{navItems.map((item) => (
<LinkComponent
key={item.key}
href={item.href}
className={cn(
"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}
<span>{item.label}</span>
</LinkComponent>
))}
{navItems.map((item) =>
item.children ? (
<NavGroup key={item.key} item={item} LinkComponent={LinkComponent} />
) : (
<LinkComponent
key={item.key}
href={item.href}
className={cn(NAV_LINK_CLASSES, item.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
>
{item.icon}
<span>{item.label}</span>
</LinkComponent>
),
)}
</nav>
</aside>