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:
@@ -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)
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
133
apps/core-web/src/routes/products/catalogs/SubgroupsListPage.tsx
Normal file
133
apps/core-web/src/routes/products/catalogs/SubgroupsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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,
|
||||
);
|
||||
return rows;
|
||||
|
||||
@@ -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,7 +104,10 @@ function NavGroup({
|
||||
</button>
|
||||
{open ? (
|
||||
<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) =>
|
||||
child.children ? (
|
||||
<NavGroup key={child.key} item={child} LinkComponent={LinkComponent} />
|
||||
) : (
|
||||
<LinkComponent
|
||||
key={child.key}
|
||||
href={child.href}
|
||||
@@ -110,7 +116,8 @@ function NavGroup({
|
||||
{child.icon}
|
||||
<span>{child.label}</span>
|
||||
</LinkComponent>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user