diff --git a/apps/api/test/product-catalogs.spec.ts b/apps/api/test/product-catalogs.spec.ts index e49bd14..a83ba03 100644 --- a/apps/api/test/product-catalogs.spec.ts +++ b/apps/api/test/product-catalogs.spec.ts @@ -130,6 +130,14 @@ describe("product catalogs — types/groups/subgroups/brands/units", () => { .expect(200); expect(list.body).toHaveLength(1); expect(list.body[0].id).toBe(subgroup.body.id); + expect(list.body[0].group_name).toBe(group.body.name); + + const unscoped = await agent(app) + .get("/api/v1/products/subgroups") + .set(...bearer(admin.accessToken)) + .expect(200); + const found = unscoped.body.find((s: { id: string }) => s.id === subgroup.body.id); + expect(found?.group_name).toBe(group.body.name); const renamed = `${subgroupName} Renomeado`; const updated = await agent(app) diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index b1be7f8..1c7ec2c 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -10,6 +10,7 @@ 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 { SubgroupsListPage } from "./routes/products/catalogs/SubgroupsListPage.js"; import { BrandsListPage } from "./routes/products/catalogs/BrandsListPage.js"; import { UnitsListPage } from "./routes/products/catalogs/UnitsListPage.js"; import { ResellersListPage } from "./routes/resellers/ResellersListPage.js"; @@ -88,6 +89,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/core-web/src/lib/api/product-catalogs.ts b/apps/core-web/src/lib/api/product-catalogs.ts index f7d00cd..42714bf 100644 --- a/apps/core-web/src/lib/api/product-catalogs.ts +++ b/apps/core-web/src/lib/api/product-catalogs.ts @@ -27,9 +27,10 @@ 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`). */ +/** Subgroups always belong to a group — `group_id` is never patchable (see `UpdateProductSubgroupDto`). `group_name` is resolved server-side via JOIN. */ export interface ProductSubgroupRow extends SimpleCatalogRow { group_id: string; + group_name: string; } export interface UpdateSimpleCatalogInput { diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index 853d4db..da17ca2 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -53,7 +53,15 @@ const NAV_ITEM_DEFS: NavItemDef[] = [ 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-groups", + label: "Grupos", + href: "/products/groups", + children: [ + { key: "products-groups-list", label: "Grupos", href: "/products/groups" }, + { key: "products-subgroups", label: "Subgrupos", href: "/products/subgroups" }, + ], + }, { key: "products-brands", label: "Marcas", href: "/products/brands" }, { key: "products-units", label: "Unidades", href: "/products/units" }, ], diff --git a/apps/core-web/src/routes/products/catalogs/ProductGroupsListPage.tsx b/apps/core-web/src/routes/products/catalogs/ProductGroupsListPage.tsx index ed56207..43e9864 100644 --- a/apps/core-web/src/routes/products/catalogs/ProductGroupsListPage.tsx +++ b/apps/core-web/src/routes/products/catalogs/ProductGroupsListPage.tsx @@ -10,10 +10,11 @@ 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. + * Catálogo de Grupo — dropdown opcional em `ProductFormPage.tsx`. Expandir + * a linha de um grupo abre `GroupSubgroupsPanel` abaixo da tabela, + * escopado a esse grupo — complementa (não substitui) a visão de todos os + * subgrupos em `SubgroupsListPage.tsx`, alcançável pelo submenu "Grupos > + * Subgrupos" em `AuthenticatedLayout.tsx`. */ export function ProductGroupsListPage() { const { accessToken } = useAuth(); diff --git a/apps/core-web/src/routes/products/catalogs/SubgroupsListPage.tsx b/apps/core-web/src/routes/products/catalogs/SubgroupsListPage.tsx new file mode 100644 index 0000000..ffaa8ab --- /dev/null +++ b/apps/core-web/src/routes/products/catalogs/SubgroupsListPage.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import type { FormEvent } from "react"; +import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui"; +import type { SelectOption, TableColumn } from "@eden/ui"; +import { + createProductSubgroup, + listProductGroups, + 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 { CatalogTable } from "./CatalogTable.js"; +import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js"; + +function toGroupOptions(groups: ProductGroupRow[]): SelectOption[] { + return groups.filter((g) => g.active).map((g) => ({ value: g.id, label: g.name })); +} + +/** + * Todos os subgrupos, de todos os grupos — destino do submenu "Grupos > + * Subgrupos" (mesma lógica de submenu aninhado do menu "Produtos"). + * Complementa, não substitui, o painel escopado a um grupo específico em + * `ProductGroupsListPage.tsx` (`GroupSubgroupsPanel`): lá o grupo já vem + * implícito do contexto; aqui, sendo uma lista de todos, o formulário + * precisa perguntar explicitamente a qual grupo o novo subgrupo pertence. + * + * `groupId` é lido via closure pelo `create` passado a `useSimpleCatalogCrud` + * — o hook reexecuta a cada render, então sempre usa o valor mais recente + * do Select antes do submit. + */ +export function SubgroupsListPage() { + const { accessToken } = useAuth(); + const [groupId, setGroupId] = useState(""); + + const { items, error, formError, creating, togglingId, create, toggleActive } = useSimpleCatalogCrud( + accessToken, + { + list: (token) => listProductSubgroups(token, {}), + create: (name, token) => createProductSubgroup({ name, groupId }, token), + update: updateProductSubgroup, + }, + "Não foi possível carregar os subgrupos.", + ); + + const [groups, setGroups] = useState(null); + useEffect(() => { + if (!accessToken) return; + let cancelled = false; + listProductGroups(accessToken) + .then((rows) => { + if (!cancelled) setGroups(rows); + }) + .catch(() => { + if (!cancelled) setGroups([]); + }); + return () => { + cancelled = true; + }; + }, [accessToken]); + + const [name, setName] = useState(""); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + const trimmed = name.trim(); + if (!trimmed || !groupId) return; + const created = await create(trimmed); + if (created) setName(""); + } + + const groupColumn: TableColumn = { + key: "group", + header: "Grupo", + render: (row) => row.group_name, + }; + + return ( +
+ + + {error ? ( + + {error} + + ) : null} + {formError ? ( + + {formError} + + ) : null} +
+
+ setName(e.target.value)} + required + /> +
+ +
+ {items === null && !error ? ( +

Carregando…

+ ) : ( + + )} +
+
+ ); +} diff --git a/docs/progress.md b/docs/progress.md index 4408b86..49f1ef9 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -646,3 +646,18 @@ Menu "Produtos" ganhou submenu: Itens/Tipos/Grupos/Marcas/Unidades (Subgrupo del ### Pendências Suítes de teste futuras vão continuar acrescentando linhas `qa-test-*` permanentes nesses 5 catálogos (sem rota de exclusão física, mesmo padrão aceito para os catálogos fiscais) — precisa de desativação manual periódica até (se algum dia for justificado) existir uma rota de exclusão física ou um processo de limpeza dedicado. "Não dá para limpar Grupo/Marca de volta para vazio via PATCH" continua uma limitação pré-existente e mais ampla do padrão `COALESCE` usado em `updateProduct` — não corrigida (sem risco de invariante quebrada, ao contrário do caso do subgrupo). + +--- + +## Submenu de Subgrupos dentro de Grupos — segundo nível de navegação + +Data: 2026-09-18. Usuário pediu, sobre a leva anterior: "dentro da opção grupos tem que abrir um sub menu de sub grupos para que fique na mesma lógica" — ou seja, um segundo nível de submenu (Produtos > Grupos > Subgrupos), não só o painel "Ver subgrupos" já existente dentro da tela de Grupos. Implementado diretamente (mudança pequena e bem entendida, sem delegar). + +`packages/ui/src/components/AppShell.tsx`: `NavGroup` passou a ser recursivo — um filho que também tem `children` agora renderiza como outro `NavGroup` aninhado (antes só 1 nível era suportado, documentado explicitamente como limitação). `withActive()` em `AuthenticatedLayout.tsx` já era recursivo (nenhuma mudança necessária ali) — só o item "Grupos" ganhou seu próprio `children`: `[Grupos (lista), Subgrupos]`. + +Nova tela `apps/core-web/src/routes/products/catalogs/SubgroupsListPage.tsx` (`/products/subgroups`) — todos os subgrupos, de todos os grupos, com coluna "Grupo" e formulário de criação que pede o Grupo explicitamente (dropdown), diferente do `GroupSubgroupsPanel` já existente (que fica dentro da tela de um Grupo específico e por isso nunca precisa perguntar "qual grupo" — o contexto já implica). As duas telas coexistem deliberadamente: uma é a visão geral alcançável pelo menu, a outra é o atalho contextual ao editar um grupo específico. + +Pequeno complemento de backend necessário para a coluna "Grupo" da tela nova: `listProductSubgroups` (`packages/database/src/repositories/product-catalogs.ts`) passou a fazer JOIN com `product_groups` e devolver `group_name` — antes só existia `group_id` cru, suficiente para o painel escopado (que já sabe o nome do grupo pelo contexto) mas não para a listagem geral. + +### Verificação +`pnpm turbo run typecheck build` 28/28 (os 3 apps que usam `@eden/ui` — `core-web`/`reseller-web`/`subscriber-web` — confirmados sem regressão). Teste novo em `apps/api/test/product-catalogs.spec.ts` cobrindo `group_name` resolvido tanto na listagem filtrada por `groupId` quanto na geral. Suíte completa: 95/95 verdes. Testado ao vivo via `curl` contra `eden-api`/`eden-core` reconstruídos: rota `/products/subgroups` responde 200, criação de subgrupo segue funcionando, listagem geral resolve `group_name` corretamente. Interação de clique no accordion aninhado (abrir "Grupos" → ver "Subgrupos" → navegar) não foi possível verificar via browser automatizado (nenhuma ferramenta desse tipo disponível neste ambiente) — verificação limitada a build/tipos/API real, mesma limitação já registrada pelo agente de frontend na leva anterior. diff --git a/packages/database/src/repositories/product-catalogs.ts b/packages/database/src/repositories/product-catalogs.ts index 137345d..f3117e9 100644 --- a/packages/database/src/repositories/product-catalogs.ts +++ b/packages/database/src/repositories/product-catalogs.ts @@ -126,6 +126,7 @@ export const updateProductGroup = (id: string, input: UpdateProductGroupInput) = export interface ProductSubgroup extends SimpleCatalogRow { group_id: string; + group_name: string; } export interface CreateProductSubgroupInput extends CreateSimpleCatalogEntryInput { @@ -142,14 +143,18 @@ export async function listProductSubgroups( const params: unknown[] = []; if (groupId) { params.push(groupId); - conditions.push(`group_id = $${params.length}`); + conditions.push(`ps.group_id = $${params.length}`); } if (!includeInactive) { - conditions.push("active = true"); + conditions.push("ps.active = true"); } const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; const { rows } = await query( - `SELECT * FROM product_subgroups ${where} ORDER BY name ASC`, + `SELECT ps.*, pg.name AS group_name + FROM product_subgroups ps + JOIN product_groups pg ON pg.id = ps.group_id + ${where} + ORDER BY ps.name ASC`, params, ); return rows; diff --git a/packages/ui/src/components/AppShell.tsx b/packages/ui/src/components/AppShell.tsx index 83612b8..e940064 100644 --- a/packages/ui/src/components/AppShell.tsx +++ b/packages/ui/src/components/AppShell.tsx @@ -12,7 +12,10 @@ export interface AppShellNavItem { * 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. + * navigated to directly. A child may itself declare `children`, nesting + * another accordion one level deeper (e.g. Produtos > Grupos > + * Subgrupos) — depth beyond that is untested but not structurally + * prevented. */ children?: AppShellNavItem[]; } @@ -101,16 +104,20 @@ function NavGroup({ {open ? (
- {(item.children ?? []).map((child) => ( - - {child.icon} - {child.label} - - ))} + {(item.children ?? []).map((child) => + child.children ? ( + + ) : ( + + {child.icon} + {child.label} + + ), + )}
) : null}