feat(products): catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade com submenu e dropdowns

Menu Produtos ganha submenu (AppShell agora suporta itens aninhados) para
cadastrar Tipos/Grupos/Marcas/Unidades; Subgrupo é gerenciado a partir da
tela do Grupo (sempre pertence a um). O cadastro de item passa a usar
dropdowns desses catálogos em vez de texto livre — exatamente o que o
comentário original da migration de products já previa como trabalho
futuro.

Backend: 5 tabelas de catálogo simples (mesmo padrão dos catálogos
fiscais), products ganha as FKs correspondentes com backfill, e
endpoints CRUD (sem exclusão física, active=false é o "apagar").

Corrige também um buraco de integridade real encontrado na integração:
PATCH /v1/products/:id usava COALESCE para os campos de catálogo
opcionais, então não dava para distinguir "não mexer" de "limpar" —
trocar o Grupo de um produto sem tocar no Subgrupo deixava o vínculo
antigo órfão e inconsistente, sem nenhuma validação pegando isso. Agora
a atualização valida o estado efetivo resultante (bloqueia com 400 em
vez de corromper o vínculo) e productSubgroupId: null limpa de verdade.

Suíte: 95/95 verdes (92→95). pnpm turbo run typecheck build: 28/28.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 23:46:14 -03:00
parent 9c14671e19
commit f38f2407ff
39 changed files with 2738 additions and 137 deletions

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import { cn } from "../utils/cn.js";
@@ -7,6 +8,13 @@ export interface AppShellNavItem {
href: string;
icon?: ReactNode;
active?: boolean;
/**
* 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.
*/
children?: AppShellNavItem[];
}
interface NavLinkRenderProps {
@@ -41,6 +49,74 @@ function DefaultLink({ href, className, children }: NavLinkRenderProps) {
);
}
const NAV_LINK_CLASSES = "flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150";
const NAV_LINK_ACTIVE_CLASSES = "bg-dark text-white";
const NAV_LINK_INACTIVE_CLASSES = "text-gray-900 hover:bg-light-700 hover:text-primary";
/**
* A nav item with `children` — an accordion button that expands/collapses a
* sublist. `item.active` is expected to already reflect "this item or one of
* its descendants matches the current route" (computed by the consuming
* app, which owns routing) — used both to highlight the button while closed
* and to auto-open the sublist so the active child is never hidden inside a
* collapsed group.
*/
function NavGroup({
item,
LinkComponent,
}: {
item: AppShellNavItem;
LinkComponent: ComponentType<NavLinkRenderProps>;
}) {
const [open, setOpen] = useState(Boolean(item.active));
useEffect(() => {
if (item.active) setOpen(true);
}, [item.active]);
return (
<div>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
className={cn("w-full", NAV_LINK_CLASSES, item.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
>
{item.icon}
<span className="flex-1 text-left">{item.label}</span>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
className={cn("size-3.5 shrink-0 transition-transform duration-150", open ? "rotate-180" : "")}
>
<path
d="M5 7.5L10 12.5L15 7.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open ? (
<div className="ms-3 mt-1 flex flex-col gap-1 border-s border-border-color ps-3">
{(item.children ?? []).map((child) => (
<LinkComponent
key={child.key}
href={child.href}
className={cn(NAV_LINK_CLASSES, child.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
>
{child.icon}
<span>{child.label}</span>
</LinkComponent>
))}
</div>
) : null}
</div>
);
}
/**
* Visually inspired by the DreamsERP theme's sidebar/topbar
* (react/src/components/common-sidebar, common-header) — sidebar with
@@ -65,21 +141,20 @@ export function AppShell({
<span className="text-sm font-bold text-title">{appName}</span>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3" aria-label="Navegação principal">
{navItems.map((item) => (
<LinkComponent
key={item.key}
href={item.href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150",
item.active
? "bg-dark text-white"
: "text-gray-900 hover:bg-light-700 hover:text-primary",
)}
>
{item.icon}
<span>{item.label}</span>
</LinkComponent>
))}
{navItems.map((item) =>
item.children ? (
<NavGroup key={item.key} item={item} LinkComponent={LinkComponent} />
) : (
<LinkComponent
key={item.key}
href={item.href}
className={cn(NAV_LINK_CLASSES, item.active ? NAV_LINK_ACTIVE_CLASSES : NAV_LINK_INACTIVE_CLASSES)}
>
{item.icon}
<span>{item.label}</span>
</LinkComponent>
),
)}
</nav>
</aside>