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:
@@ -8,6 +8,10 @@ import { CompaniesListPage } from "./routes/companies/CompaniesListPage.js";
|
||||
import { CompanyFormPage } from "./routes/companies/CompanyFormPage.js";
|
||||
import { ProductsListPage } from "./routes/products/ProductsListPage.js";
|
||||
import { ProductFormPage } from "./routes/products/ProductFormPage.js";
|
||||
import { ProductTypesListPage } from "./routes/products/catalogs/ProductTypesListPage.js";
|
||||
import { ProductGroupsListPage } from "./routes/products/catalogs/ProductGroupsListPage.js";
|
||||
import { BrandsListPage } from "./routes/products/catalogs/BrandsListPage.js";
|
||||
import { UnitsListPage } from "./routes/products/catalogs/UnitsListPage.js";
|
||||
import { ResellersListPage } from "./routes/resellers/ResellersListPage.js";
|
||||
import { ResellerFormPage } from "./routes/resellers/ResellerFormPage.js";
|
||||
import { LeadsListPage } from "./routes/crm/LeadsListPage.js";
|
||||
@@ -82,6 +86,10 @@ export function App() {
|
||||
<Route path="/products" element={<ProductsListPage />} />
|
||||
<Route path="/products/new" element={<ProductFormPage />} />
|
||||
<Route path="/products/:id/edit" element={<ProductFormPage />} />
|
||||
<Route path="/products/types" element={<ProductTypesListPage />} />
|
||||
<Route path="/products/groups" element={<ProductGroupsListPage />} />
|
||||
<Route path="/products/brands" element={<BrandsListPage />} />
|
||||
<Route path="/products/units" element={<UnitsListPage />} />
|
||||
|
||||
<Route path="/resellers" element={<ResellersListPage />} />
|
||||
<Route path="/resellers/new" element={<ResellerFormPage />} />
|
||||
|
||||
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;
|
||||
|
||||
@@ -10,28 +10,74 @@ function isNavItemActive(href: string, pathname: string): boolean {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
interface NavItemDef {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
children?: NavItemDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes `active` bottom-up. Leaves are matched with `isNavItemActive`
|
||||
* against their own href; when siblings overlap by prefix (e.g. "Itens"
|
||||
* href=`/products` is a `/`-prefix of "Tipos" href=`/products/types`), only
|
||||
* the most specific (longest href) match is marked active, so navigating to
|
||||
* `/products/types` never also lights up "Itens". A group item (has
|
||||
* `children`) is active exactly when one of its children is — its own
|
||||
* `href` is a placeholder (it renders as an accordion button, never a
|
||||
* direct link) and is never matched on its own.
|
||||
*/
|
||||
function withActive(items: NavItemDef[], pathname: string): AppShellNavItem[] {
|
||||
const leafMatches = items.filter((item) => !item.children && isNavItemActive(item.href, pathname));
|
||||
const mostSpecific = leafMatches.reduce<NavItemDef | null>(
|
||||
(best, item) => (!best || item.href.length > best.href.length ? item : best),
|
||||
null,
|
||||
);
|
||||
|
||||
return items.map((item) => {
|
||||
if (item.children) {
|
||||
const children = withActive(item.children, pathname);
|
||||
return { ...item, children, active: children.some((child) => child.active) };
|
||||
}
|
||||
return { ...item, active: item === mostSpecific };
|
||||
});
|
||||
}
|
||||
|
||||
const NAV_ITEM_DEFS: NavItemDef[] = [
|
||||
{ key: "dashboard", label: "Painel", href: "/" },
|
||||
{ key: "companies", label: "Empresas", href: "/companies" },
|
||||
{
|
||||
key: "products",
|
||||
label: "Produtos",
|
||||
href: "/products",
|
||||
children: [
|
||||
{ key: "products-list", label: "Itens", href: "/products" },
|
||||
{ key: "products-types", label: "Tipos", href: "/products/types" },
|
||||
{ key: "products-groups", label: "Grupos", href: "/products/groups" },
|
||||
{ key: "products-brands", label: "Marcas", href: "/products/brands" },
|
||||
{ key: "products-units", label: "Unidades", href: "/products/units" },
|
||||
],
|
||||
},
|
||||
{ key: "resellers", label: "Revendas", href: "/resellers" },
|
||||
{ key: "leads", label: "CRM", href: "/leads" },
|
||||
{ key: "quotes", label: "Ofertas", href: "/quotes" },
|
||||
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" },
|
||||
{ key: "contracts", label: "Contratos", href: "/contracts" },
|
||||
{ key: "documents", label: "Documentos", href: "/documents/templates" },
|
||||
{ key: "signatures", label: "Assinaturas", href: "/signatures" },
|
||||
{ key: "inventory", label: "Estoque", href: "/inventory/assets" },
|
||||
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
|
||||
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
|
||||
{ key: "support", label: "Suporte", href: "/support/tickets" },
|
||||
{ key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" },
|
||||
];
|
||||
|
||||
export function AuthenticatedLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const navItems: AppShellNavItem[] = [
|
||||
{ key: "dashboard", label: "Painel", href: "/" },
|
||||
{ key: "companies", label: "Empresas", href: "/companies" },
|
||||
{ key: "products", label: "Produtos", href: "/products" },
|
||||
{ key: "resellers", label: "Revendas", href: "/resellers" },
|
||||
{ key: "leads", label: "CRM", href: "/leads" },
|
||||
{ key: "quotes", label: "Ofertas", href: "/quotes" },
|
||||
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" },
|
||||
{ key: "contracts", label: "Contratos", href: "/contracts" },
|
||||
{ key: "documents", label: "Documentos", href: "/documents/templates" },
|
||||
{ key: "signatures", label: "Assinaturas", href: "/signatures" },
|
||||
{ key: "inventory", label: "Estoque", href: "/inventory/assets" },
|
||||
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
|
||||
{ key: "fiscal", label: "Fiscal", href: "/fiscal/ncm" },
|
||||
{ key: "support", label: "Suporte", href: "/support/tickets" },
|
||||
{ key: "hr-timeclock", label: "Ponto Eletrônico", href: "/hr-timeclock/devices" },
|
||||
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
|
||||
const navItems = withActive(NAV_ITEM_DEFS, location.pathname);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
|
||||
@@ -2,15 +2,38 @@ import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import type { SelectOption } from "@eden/ui";
|
||||
import { SERVICE_TYPES, TARIFF_TYPES } from "@eden/domain-shared";
|
||||
import type { ServiceType, TariffType } from "@eden/domain-shared";
|
||||
import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js";
|
||||
import type { CreateProductInput } from "../../lib/api/products.js";
|
||||
import {
|
||||
listBrands,
|
||||
listProductGroups,
|
||||
listProductSubgroups,
|
||||
listProductTypes,
|
||||
listUnits,
|
||||
} from "../../lib/api/product-catalogs.js";
|
||||
import type {
|
||||
BrandRow,
|
||||
ProductGroupRow,
|
||||
ProductSubgroupRow,
|
||||
ProductTypeRow,
|
||||
UnitRow,
|
||||
} from "../../lib/api/product-catalogs.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { formatCurrencyBRL } from "../../lib/format.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ProductFiscalSection } from "./ProductFiscalSection.js";
|
||||
|
||||
/** Only `active` rows populate a dropdown, except the row currently selected on an existing product — kept visible (even if meanwhile deactivated) so editing never silently blanks the field. */
|
||||
function toCatalogOptions<T extends { id: string; name: string; active: boolean }>(
|
||||
rows: T[],
|
||||
selectedId: string,
|
||||
): SelectOption[] {
|
||||
return rows.filter((row) => row.active || row.id === selectedId).map((row) => ({ value: row.id, label: row.name }));
|
||||
}
|
||||
|
||||
/** Money fields are edited/stored as integer cents so the mask never loses precision to floating point. */
|
||||
function centsToDisplay(cents: number): string {
|
||||
return formatCurrencyBRL(cents / 100);
|
||||
@@ -44,11 +67,11 @@ function CurrencyInput({
|
||||
interface ProductFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
productType: string;
|
||||
productGroup: string;
|
||||
productSubgroup: string;
|
||||
brand: string;
|
||||
unit: string;
|
||||
productTypeId: string;
|
||||
productGroupId: string;
|
||||
productSubgroupId: string;
|
||||
brandId: string;
|
||||
unitId: string;
|
||||
active: boolean;
|
||||
discontinued: boolean;
|
||||
tracksStock: boolean;
|
||||
@@ -78,11 +101,11 @@ function emptyTariffRatesCents(): ProductFormState["tariffRatesCents"] {
|
||||
const EMPTY_FORM: ProductFormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
productType: "",
|
||||
productGroup: "",
|
||||
productSubgroup: "",
|
||||
brand: "",
|
||||
unit: "",
|
||||
productTypeId: "",
|
||||
productGroupId: "",
|
||||
productSubgroupId: "",
|
||||
brandId: "",
|
||||
unitId: "",
|
||||
active: true,
|
||||
discontinued: false,
|
||||
tracksStock: false,
|
||||
@@ -128,11 +151,11 @@ export function ProductFormPage() {
|
||||
setForm({
|
||||
name: product.name,
|
||||
description: product.description ?? "",
|
||||
productType: product.product_type,
|
||||
productGroup: product.product_group ?? "",
|
||||
productSubgroup: product.product_subgroup ?? "",
|
||||
brand: product.brand ?? "",
|
||||
unit: product.unit,
|
||||
productTypeId: product.product_type_id,
|
||||
productGroupId: product.product_group_id ?? "",
|
||||
productSubgroupId: product.product_subgroup_id ?? "",
|
||||
brandId: product.brand_id ?? "",
|
||||
unitId: product.unit_id,
|
||||
active: product.active,
|
||||
discontinued: product.discontinued,
|
||||
tracksStock: product.tracks_stock,
|
||||
@@ -171,6 +194,75 @@ export function ProductFormPage() {
|
||||
};
|
||||
}, [id, accessToken]);
|
||||
|
||||
const [catalogs, setCatalogs] = useState<{
|
||||
types: ProductTypeRow[];
|
||||
groups: ProductGroupRow[];
|
||||
brands: BrandRow[];
|
||||
units: UnitRow[];
|
||||
} | null>(null);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
Promise.all([listProductTypes(accessToken), listProductGroups(accessToken), listBrands(accessToken), listUnits(accessToken)])
|
||||
.then(([types, groups, brands, units]) => {
|
||||
if (!cancelled) setCatalogs({ types, groups, brands, units });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setCatalogError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "Não foi possível carregar os catálogos de tipo/grupo/marca/unidade.",
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
// Subgrupo é sempre escopado a um grupo — o dropdown só é populado depois
|
||||
// que um grupo é escolhido, refazendo o fetch a cada troca de grupo
|
||||
// (`groupId=<grupo>`), nunca a lista geral de subgrupos.
|
||||
const [subgroups, setSubgroups] = useState<ProductSubgroupRow[]>([]);
|
||||
const [subgroupsLoading, setSubgroupsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !form.productGroupId) {
|
||||
setSubgroups([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSubgroupsLoading(true);
|
||||
listProductSubgroups(accessToken, { groupId: form.productGroupId })
|
||||
.then((rows) => {
|
||||
if (!cancelled) setSubgroups(rows);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSubgroups([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSubgroupsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken, form.productGroupId]);
|
||||
|
||||
// Se o subgrupo selecionado não pertence (mais) ao grupo atual — porque o
|
||||
// grupo acabou de mudar — limpa a seleção em vez de deixar o formulário
|
||||
// silenciosamente prestes a mandar uma combinação grupo/subgrupo inválida.
|
||||
useEffect(() => {
|
||||
if (subgroupsLoading || !form.productSubgroupId) return;
|
||||
const stillValid = subgroups.some((s) => s.id === form.productSubgroupId);
|
||||
if (!stillValid) {
|
||||
setForm((prev) => ({ ...prev, productSubgroupId: "" }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subgroups, subgroupsLoading]);
|
||||
|
||||
function updateField<K extends keyof ProductFormState>(field: K, value: ProductFormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
@@ -184,11 +276,15 @@ export function ProductFormPage() {
|
||||
const payload: CreateProductInput = {
|
||||
name: form.name.trim(),
|
||||
description: toOptional(form.description),
|
||||
productType: form.productType.trim(),
|
||||
productGroup: toOptional(form.productGroup),
|
||||
productSubgroup: toOptional(form.productSubgroup),
|
||||
brand: toOptional(form.brand),
|
||||
unit: toOptional(form.unit),
|
||||
productTypeId: form.productTypeId,
|
||||
productGroupId: toOptional(form.productGroupId),
|
||||
// `null` (not `undefined`) when empty — PATCH treats an omitted
|
||||
// field as "leave unchanged" but `null` as an explicit clear
|
||||
// (needed when the group changed and the old subgroup no longer
|
||||
// applies, per the auto-clear effect above). Harmless for POST.
|
||||
productSubgroupId: form.productSubgroupId.trim() === "" ? null : form.productSubgroupId.trim(),
|
||||
brandId: toOptional(form.brandId),
|
||||
unitId: form.unitId,
|
||||
active: form.active,
|
||||
discontinued: form.discontinued,
|
||||
tracksStock: form.tracksStock,
|
||||
@@ -226,6 +322,12 @@ export function ProductFormPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const productTypeOptions = catalogs ? toCatalogOptions(catalogs.types, form.productTypeId) : [];
|
||||
const unitOptions = catalogs ? toCatalogOptions(catalogs.units, form.unitId) : [];
|
||||
const groupOptions = catalogs ? toCatalogOptions(catalogs.groups, form.productGroupId) : [];
|
||||
const brandOptions = catalogs ? toCatalogOptions(catalogs.brands, form.brandId) : [];
|
||||
const subgroupOptions = toCatalogOptions(subgroups, form.productSubgroupId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -253,6 +355,12 @@ export function ProductFormPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{catalogError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{catalogError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-default">Carregando…</p>
|
||||
@@ -264,24 +372,51 @@ export function ProductFormPage() {
|
||||
<div className="sm:col-span-2">
|
||||
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
|
||||
</div>
|
||||
<Input
|
||||
<Select
|
||||
label="Tipo"
|
||||
value={form.productType}
|
||||
onChange={(e) => updateField("productType", e.target.value)}
|
||||
options={productTypeOptions}
|
||||
value={form.productTypeId}
|
||||
onChange={(e) => updateField("productTypeId", e.target.value)}
|
||||
placeholder={catalogs === null ? "Carregando…" : "Selecione um tipo"}
|
||||
required
|
||||
/>
|
||||
<Input label="Unidade" placeholder="unidade" value={form.unit} onChange={(e) => updateField("unit", e.target.value)} />
|
||||
<Input
|
||||
<Select
|
||||
label="Unidade"
|
||||
options={unitOptions}
|
||||
value={form.unitId}
|
||||
onChange={(e) => updateField("unitId", e.target.value)}
|
||||
placeholder={catalogs === null ? "Carregando…" : "Selecione uma unidade"}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Grupo"
|
||||
value={form.productGroup}
|
||||
onChange={(e) => updateField("productGroup", e.target.value)}
|
||||
options={groupOptions}
|
||||
value={form.productGroupId}
|
||||
onChange={(e) => updateField("productGroupId", e.target.value)}
|
||||
placeholder={catalogs === null ? "Carregando…" : "Nenhum"}
|
||||
/>
|
||||
<Input
|
||||
<Select
|
||||
label="Subgrupo"
|
||||
value={form.productSubgroup}
|
||||
onChange={(e) => updateField("productSubgroup", e.target.value)}
|
||||
options={subgroupOptions}
|
||||
value={form.productSubgroupId}
|
||||
onChange={(e) => updateField("productSubgroupId", e.target.value)}
|
||||
disabled={!form.productGroupId}
|
||||
placeholder={
|
||||
!form.productGroupId
|
||||
? "Selecione um grupo primeiro"
|
||||
: subgroupsLoading
|
||||
? "Carregando…"
|
||||
: "Nenhum"
|
||||
}
|
||||
hint={!form.productGroupId ? "Escolha um grupo para habilitar o subgrupo." : undefined}
|
||||
/>
|
||||
<Select
|
||||
label="Marca"
|
||||
options={brandOptions}
|
||||
value={form.brandId}
|
||||
onChange={(e) => updateField("brandId", e.target.value)}
|
||||
placeholder={catalogs === null ? "Carregando…" : "Nenhuma"}
|
||||
/>
|
||||
<Input label="Marca" value={form.brand} onChange={(e) => updateField("brand", e.target.value)} />
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900" htmlFor="product-description">
|
||||
Descrição
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ProductsListPage() {
|
||||
const columns: Array<TableColumn<Product>> = [
|
||||
{ key: "code", header: "Código", render: (product) => `#${product.product_code}` },
|
||||
{ key: "name", header: "Nome", render: (product) => <span className="font-semibold text-title">{product.name}</span> },
|
||||
{ key: "type", header: "Tipo", render: (product) => product.product_type },
|
||||
{ key: "type", header: "Tipo", render: (product) => product.product_type_name },
|
||||
...PRICE_COLUMNS.map(
|
||||
(column): TableColumn<Product> => ({
|
||||
key: column.key,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user