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

@@ -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() {
<Route path="/products/:id/edit" element={<ProductFormPage />} />
<Route path="/products/types" element={<ProductTypesListPage />} />
<Route path="/products/groups" element={<ProductGroupsListPage />} />
<Route path="/products/subgroups" element={<SubgroupsListPage />} />
<Route path="/products/brands" element={<BrandsListPage />} />
<Route path="/products/units" element={<UnitsListPage />} />

View File

@@ -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 {

View File

@@ -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" },
],

View File

@@ -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();

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>
);
}