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