feat(products): submenu de Subgrupos aninhado dentro de Grupos

Mesma lógica do submenu "Produtos" já existente: "Grupos" agora expande
seu próprio submenu com "Subgrupos" (AppShell ganha suporte a um
segundo nível de accordion aninhado). Nova tela lista todos os
subgrupos de todos os grupos, com coluna Grupo e criação via dropdown
de grupo explícito — complementa (não substitui) o painel já existente
dentro da tela de um grupo específico.

listProductSubgroups passa a resolver group_name via JOIN.

Suíte: 95/95. pnpm turbo run typecheck build: 28/28 (core-web,
reseller-web e subscriber-web confirmados sem regressão no AppShell
compartilhado).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 08:52:19 -03:00
parent f38f2407ff
commit 96200dc2ba
9 changed files with 200 additions and 20 deletions

View File

@@ -130,6 +130,14 @@ describe("product catalogs — types/groups/subgroups/brands/units", () => {
.expect(200); .expect(200);
expect(list.body).toHaveLength(1); expect(list.body).toHaveLength(1);
expect(list.body[0].id).toBe(subgroup.body.id); 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 renamed = `${subgroupName} Renomeado`;
const updated = await agent(app) const updated = await agent(app)

View File

@@ -10,6 +10,7 @@ import { ProductsListPage } from "./routes/products/ProductsListPage.js";
import { ProductFormPage } from "./routes/products/ProductFormPage.js"; import { ProductFormPage } from "./routes/products/ProductFormPage.js";
import { ProductTypesListPage } from "./routes/products/catalogs/ProductTypesListPage.js"; import { ProductTypesListPage } from "./routes/products/catalogs/ProductTypesListPage.js";
import { ProductGroupsListPage } from "./routes/products/catalogs/ProductGroupsListPage.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 { BrandsListPage } from "./routes/products/catalogs/BrandsListPage.js";
import { UnitsListPage } from "./routes/products/catalogs/UnitsListPage.js"; import { UnitsListPage } from "./routes/products/catalogs/UnitsListPage.js";
import { ResellersListPage } from "./routes/resellers/ResellersListPage.js"; import { ResellersListPage } from "./routes/resellers/ResellersListPage.js";
@@ -88,6 +89,7 @@ export function App() {
<Route path="/products/:id/edit" element={<ProductFormPage />} /> <Route path="/products/:id/edit" element={<ProductFormPage />} />
<Route path="/products/types" element={<ProductTypesListPage />} /> <Route path="/products/types" element={<ProductTypesListPage />} />
<Route path="/products/groups" element={<ProductGroupsListPage />} /> <Route path="/products/groups" element={<ProductGroupsListPage />} />
<Route path="/products/subgroups" element={<SubgroupsListPage />} />
<Route path="/products/brands" element={<BrandsListPage />} /> <Route path="/products/brands" element={<BrandsListPage />} />
<Route path="/products/units" element={<UnitsListPage />} /> <Route path="/products/units" element={<UnitsListPage />} />

View File

@@ -27,9 +27,10 @@ export type ProductGroupRow = SimpleCatalogRow;
export type BrandRow = SimpleCatalogRow; export type BrandRow = SimpleCatalogRow;
export type UnitRow = 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 { export interface ProductSubgroupRow extends SimpleCatalogRow {
group_id: string; group_id: string;
group_name: string;
} }
export interface UpdateSimpleCatalogInput { export interface UpdateSimpleCatalogInput {

View File

@@ -53,7 +53,15 @@ const NAV_ITEM_DEFS: NavItemDef[] = [
children: [ children: [
{ key: "products-list", label: "Itens", href: "/products" }, { key: "products-list", label: "Itens", href: "/products" },
{ key: "products-types", label: "Tipos", href: "/products/types" }, { 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-brands", label: "Marcas", href: "/products/brands" },
{ key: "products-units", label: "Unidades", href: "/products/units" }, { key: "products-units", label: "Unidades", href: "/products/units" },
], ],

View File

@@ -10,10 +10,11 @@ import { GroupSubgroupsPanel } from "./GroupSubgroupsPanel.js";
import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js"; import { useSimpleCatalogCrud } from "./useSimpleCatalogCrud.js";
/** /**
* Catálogo de Grupo — dropdown opcional em `ProductFormPage.tsx`. Também é * Catálogo de Grupo — dropdown opcional em `ProductFormPage.tsx`. Expandir
* a única tela que expõe os Subgrupos (sem entrada própria no menu, ver * a linha de um grupo abre `GroupSubgroupsPanel` abaixo da tabela,
* `AuthenticatedLayout.tsx`): expandir a linha de um grupo abre * escopado a esse grupo — complementa (não substitui) a visão de todos os
* `GroupSubgroupsPanel` abaixo da tabela, escopado a esse grupo. * subgrupos em `SubgroupsListPage.tsx`, alcançável pelo submenu "Grupos >
* Subgrupos" em `AuthenticatedLayout.tsx`.
*/ */
export function ProductGroupsListPage() { export function ProductGroupsListPage() {
const { accessToken } = useAuth(); const { accessToken } = useAuth();

View File

@@ -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<ProductSubgroupRow>(
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<ProductGroupRow[] | null>(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<ProductSubgroupRow> = {
key: "group",
header: "Grupo",
render: (row) => row.group_name,
};
return (
<div>
<PageHeader
title="Produtos — Subgrupos"
description="Subgrupo do produto/serviço, sempre vinculado a um grupo. Campo opcional no cadastro de produto."
/>
<Card>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{formError ? (
<Alert variant="error" className="mb-3">
{formError}
</Alert>
) : null}
<form className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end" onSubmit={handleSubmit}>
<div className="flex-1">
<Select
label="Grupo"
options={groups ? toGroupOptions(groups) : []}
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
placeholder={groups === null ? "Carregando…" : "Selecione um grupo"}
required
/>
</div>
<div className="flex-1">
<Input
label="Nome"
placeholder="ex.: Fibra residencial"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<Button type="submit" loading={creating}>
Adicionar subgrupo
</Button>
</form>
{items === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<CatalogTable
items={items ?? []}
togglingId={togglingId}
onToggleActive={toggleActive}
emptyMessage="Nenhum subgrupo cadastrado."
extraColumn={groupColumn}
/>
)}
</Card>
</div>
);
}

View File

@@ -646,3 +646,18 @@ Menu "Produtos" ganhou submenu: Itens/Tipos/Grupos/Marcas/Unidades (Subgrupo del
### Pendências ### 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). 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.

View File

@@ -126,6 +126,7 @@ export const updateProductGroup = (id: string, input: UpdateProductGroupInput) =
export interface ProductSubgroup extends SimpleCatalogRow { export interface ProductSubgroup extends SimpleCatalogRow {
group_id: string; group_id: string;
group_name: string;
} }
export interface CreateProductSubgroupInput extends CreateSimpleCatalogEntryInput { export interface CreateProductSubgroupInput extends CreateSimpleCatalogEntryInput {
@@ -142,14 +143,18 @@ export async function listProductSubgroups(
const params: unknown[] = []; const params: unknown[] = [];
if (groupId) { if (groupId) {
params.push(groupId); params.push(groupId);
conditions.push(`group_id = $${params.length}`); conditions.push(`ps.group_id = $${params.length}`);
} }
if (!includeInactive) { if (!includeInactive) {
conditions.push("active = true"); conditions.push("ps.active = true");
} }
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<ProductSubgroup>( const { rows } = await query<ProductSubgroup>(
`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, params,
); );
return rows; return rows;

View File

@@ -12,7 +12,10 @@ export interface AppShellNavItem {
* When present (even if empty), this item renders as an expandable * When present (even if empty), this item renders as an expandable
* accordion group instead of a direct link — `href` is then only a * accordion group instead of a direct link — `href` is then only a
* placeholder (typically duplicated by the first child) and is never * 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[]; children?: AppShellNavItem[];
} }
@@ -101,16 +104,20 @@ function NavGroup({
</button> </button>
{open ? ( {open ? (
<div className="ms-3 mt-1 flex flex-col gap-1 border-s border-border-color ps-3"> <div className="ms-3 mt-1 flex flex-col gap-1 border-s border-border-color ps-3">
{(item.children ?? []).map((child) => ( {(item.children ?? []).map((child) =>
<LinkComponent child.children ? (
key={child.key} <NavGroup key={child.key} item={child} LinkComponent={LinkComponent} />
href={child.href} ) : (
className={cn(NAV_LINK_CLASSES, child.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)} <LinkComponent
> key={child.key}
{child.icon} href={child.href}
<span>{child.label}</span> className={cn(NAV_LINK_CLASSES, child.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
</LinkComponent> >
))} {child.icon}
<span>{child.label}</span>
</LinkComponent>
),
)}
</div> </div>
) : null} ) : null}
</div> </div>