- 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>
311 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|