Files
mini-erp/mini-frontend/src/layout/MainLayout.tsx
T
GeorgeandClaude Sonnet 5 7e0a646853 Agregar flujo de inmovilización/liberación de lotes (Inmovilizar, Liberar y Confirmar Liberación Lote)
- Inmovilizar Lotes: selección múltiple de almacén origen con cantidad editable, genera Solicitud de Transferencia hacia un almacén de inmovilizados.
- Liberar Lotes: inversa de la anterior, con almacenes/campos de usuario propios (U_SIS_MOVINV, U_EXX_TIPOOPER, U_SIS_TIPDESP, U_SIS_MOTALM).
- Confirmar Liberación Lote: lista las Solicitudes abiertas pendientes (U_SIS_MOTALM=108) y, por cada lote, resuelve contra el historial de transferencias en HANA a qué almacén(es) debe volver, repartiendo la cantidad entre orígenes; permite editar cantidad, almacén destino, y agregar/quitar líneas antes de actualizar la Solicitud en SAP.
- Backend: nuevo endpoint /api/batch/lot-source-warehouse (HANA) para resolver el origen histórico de un lote; endpoint genérico /api/Catalogs/UdfValidValues (Service Layer) para valores válidos de un UDF; PATCH genérico ahora reenvía cualquier header B1S-* que mande el frontend en vez de decidirlos en el backend.
- Registro de rutas, menú y permisos para las tres pantallas nuevas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:08:58 -05:00

311 lines
11 KiB
TypeScript

import { useState, useEffect } from "react";
import { Link, Outlet, useLocation } from "react-router-dom";
import { API_URL, getToken, getUsername, getCompany } from "../utils/api";
import Icon from "../components/ui/Icon";
// Estructura del menú tipada para SAP Business One
interface SubMenuItem {
name: string;
path: string;
}
interface MenuItem {
title: string;
icon: string;
subItems: SubMenuItem[];
}
const menuConfig: MenuItem[] = [
{
title: "0. Dashboard",
icon: "chart",
subItems: [{ name: "Inicio", path: "/dashboard" }],
},
{
title: "1. Inventario",
icon: "box",
subItems: [
{ name: "Articulos", path: "/inventario/articulos" },
{ name: "Lotes", path: "/inventario/lotes" },
{ name: "Inmovilizar Lotes", path: "/inventario/inmovilizar-lotes" },
{ name: "Liberar Lotes", path: "/inventario/liberar-lotes" },
{ name: "Confirmar Liberación Lote", path: "/inventario/confirmar-liberacion-lote" },
],
},
{
title: "2. Socio de negocios",
icon: "users",
subItems: [
{ name: "Clientes", path: "/socios/clientes" },
{ name: "Proveedores", path: "/socios/proveedores" },
],
},
{
title: "3. Ventas",
icon: "cart",
subItems: [
{ name: "Ordenes de venta", path: "/ventas/ordenes" },
{ name: "Entregas o Guías", path: "/ventas/entrega" },
{ name: "Facturas de venta", path: "/ventas/facturas" },
],
},
{
title: "4. Compras",
icon: "briefcase",
subItems: [
{ name: "Ordenes de compra", path: "/compras/ordenes" },
{ name: "Recepciones de compras", path: "/compras/recepcion" },
{ name: "Facturas de proveedores", path: "/compras/facturas" },
],
},
{
title: "5. Informes",
icon: "chart",
subItems: [
{ name: "Socios de negocios", path: "/informes/socios" },
{ name: "Ventas", path: "/informes/ventas" },
{ name: "Compras", path: "/informes/compras" },
{ name: "Inventario", path: "/informes/inventario" },
],
},
{
title: "6. Reportes Crystal",
icon: "chart",
subItems: [
{ name: "Socios de negocios", path: "/reportes-crystal/socios" },
{ name: "Ventas", path: "/reportes-crystal/ventas" },
{ name: "Compras", path: "/reportes-crystal/compras" },
{ name: "Inventario", path: "/reportes-crystal/inventario" },
],
},
{
title: "7. Reportes HTML",
icon: "tag",
subItems: [
{ name: "Socios de negocios", path: "/reportes-html/socios" },
{ name: "Ventas", path: "/reportes-html/ventas" },
{ name: "Compras", path: "/reportes-html/compras" },
{ name: "Inventario", path: "/reportes-html/inventario" },
],
},
];
export default function MainLayout() {
const location = useLocation();
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [currentTheme, setCurrentTheme] = useState<string>(
() => localStorage.getItem("sap_theme") || "theme-default",
);
const [openModules, setOpenModules] = useState<Record<string, boolean>>({
"1. Inventario": true,
"2. Socio de negocios": true,
"5. Informes": true,
});
const [allowedPaths, setAllowedPaths] = useState<string[] | null>(null);
const [permissionsError, setPermissionsError] = useState(false);
useEffect(() => {
const fetchPermissions = async () => {
const token = getToken();
if (!token) return;
try {
const res = await fetch(`${API_URL}/api/user/menu`, {
headers: { "X-Session-Token": token },
});
if (!res.ok) throw new Error("No se pudo cargar el menú");
const data = await res.json();
setAllowedPaths(data.allowedPaths);
} catch {
setPermissionsError(true);
}
};
fetchPermissions();
}, []);
useEffect(() => {
document.documentElement.className = currentTheme;
localStorage.setItem("sap_theme", currentTheme);
}, [currentTheme]);
const pathMatches = (path: string) => {
if (!allowedPaths) return false;
return allowedPaths.some(
(p) =>
p === "*" ||
(p.endsWith("/*") && path.startsWith(p.slice(0, -2))) ||
p === path,
);
};
const toggleModule = (title: string) => {
setOpenModules((prev) => ({ ...prev, [title]: !prev[title] }));
};
const username = getUsername();
const company = getCompany();
const handleLogout = async () => {
const token = getToken();
if (token) {
try {
await fetch(`${API_URL}/api/Logout`, {
method: "POST",
headers: { "X-Session-Token": token },
});
} catch {
/* ignore */
}
}
localStorage.removeItem("sap_session");
window.location.href = "/login";
};
return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg)] font-sans antialiased text-[var(--color-text)]">
{/* Menu lateral (Sidebar) */}
<aside
className={`border-r border-[var(--color-border)] bg-[var(--color-surface)] flex flex-col shrink-0 select-none transition-none ${
isSidebarOpen ? "w-64" : "w-12"
}`}
>
{/* Encabezado del Menú */}
<div className="h-12 border-b border-[var(--color-border)] flex items-center px-3 justify-between bg-[var(--color-bg)]">
{isSidebarOpen && (
<span className="text-xs font-bold uppercase tracking-wider text-[var(--color-sidebar-title)]">
Main Menu
</span>
)}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="text-sm font-medium p-1 hover:bg-[var(--color-sidebar-hover)] cursor-pointer text-[var(--color-sidebar-title)] border-none"
title="Alternar Menú"
>
{isSidebarOpen ? "◀" : "▶"}
</button>
</div>
{/* Listado Jerárquico de Módulos */}
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-2 space-y-1">
{permissionsError && isSidebarOpen && (
<div className="mx-2 my-1 p-2 text-[11px] text-[var(--color-error)] bg-[var(--color-error-bg)] border border-[var(--color-error-border)]">
No se pudo cargar el menú. Intenta recargar la página.
</div>
)}
{menuConfig
.map((module) => ({
...module,
subItems: module.subItems.filter((sub) => pathMatches(sub.path)),
}))
.filter((module) => module.subItems.length > 0)
.map((module) => {
const isOpen = openModules[module.title];
return (
<div key={module.title} className="w-full">
{/* Nivel 1: Módulo */}
<button
onClick={() => isSidebarOpen && toggleModule(module.title)}
className={`w-full flex items-center px-3 py-2 text-xs font-semibold text-[var(--color-sidebar-title)] border-none text-left cursor-pointer ${
isSidebarOpen
? "hover:bg-[var(--color-sidebar-hover)]"
: "justify-center hover:bg-[var(--color-sidebar-hover)]"
}`}
disabled={!isSidebarOpen}
>
<Icon name={module.icon} className="w-4 h-4 shrink-0" />
{isSidebarOpen && (
<>
<span className="ml-2 flex-1 truncate">
{module.title}
</span>
<span className="text-[9px] text-[var(--color-text-muted)]">
{isOpen ? "▼" : "▶"}
</span>
</>
)}
</button>
{/* Nivel 2: Submódulos (Solo si está expandido y el menú está abierto) */}
{isSidebarOpen && isOpen && (
<div className="bg-[var(--color-sidebar-submenu-bg)] border-y border-[var(--color-sidebar-submenu-border)] py-1">
{module.subItems.map((subItem) => {
const isActive = location.pathname === subItem.path;
return (
<Link
key={subItem.path}
to={subItem.path}
className={`block pl-8 pr-3 py-1.5 text-xs truncate border-l-2 decoration-transparent ${
isActive
? "border-[var(--color-primary)] bg-[var(--color-primary-active)] text-[var(--color-primary)] font-medium"
: "border-transparent text-[var(--color-sidebar-text)] hover:bg-[var(--color-sidebar-hover)]"
}`}
>
{subItem.name}
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
{/* Contenedor principal */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Barra superior (TopBar Flat) */}
<header className="h-12 border-b border-[var(--color-border)] bg-[var(--color-header-bg)] text-white flex items-center justify-between px-4 select-none">
<div className="flex items-center space-x-3">
<span className="font-bold text-base tracking-tight">SAP</span>
<span className="text-xs text-[var(--color-header-muted)] border-l border-[var(--color-header-border)] pl-3 font-normal">
Web Client — {company || "SAP Business One"}
</span>
</div>
<div className="flex items-center space-x-4 text-xs">
<div className="flex items-center space-x-1.5">
<span className="text-[var(--color-header-muted)]">Usuario:</span>
<span className="font-medium">{username || "manager"}</span>
</div>
<select
value={currentTheme}
onChange={(e) => setCurrentTheme(e.target.value)}
className="bg-transparent border border-[var(--color-header-border)] text-white px-2 py-1 text-[11px] cursor-pointer rounded-none outline-none"
>
<option value="theme-default" className="text-black">
Default
</option>
<option value="theme-flat-light" className="text-black">
Flat Light
</option>
<option value="theme-enterprise" className="text-black">
Enterprise
</option>
<option value="theme-ocean" className="text-black">
Ocean
</option>
<option value="theme-slate" className="text-black">
Slate
</option>
<option value="theme-terra" className="text-black">
Terra
</option>
</select>
<button
onClick={handleLogout}
className="bg-transparent border border-[var(--color-header-border)] hover:bg-[var(--color-danger-hover)] text-white px-2 py-1 text-[11px] font-medium cursor-pointer rounded-none"
>
Cerrar Sesión
</button>
</div>
</header>
{/* Espacio de trabajo (Renderiza cada submódulo) */}
<main className="flex-1 overflow-auto bg-[var(--color-surface)]">
<Outlet />
</main>
</div>
</div>
);
}