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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user