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:
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 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,
|
||||
|
||||
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 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]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user