Initial commit: mini-erp (backend FastAPI + frontend React/Vite)
Base para el flujo de ramas por cliente. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
ab3c613165
154 files changed
+16872
No files matched your search
@@ -0,0 +1,306 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { API_URL, getToken, getUsername, getCompany } from "../utils/api";
|
||||
|
||||
// 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: "📊",
|
||||
subItems: [{ name: "Inicio", path: "/dashboard" }],
|
||||
},
|
||||
{
|
||||
title: "1. Inventario",
|
||||
icon: "📦",
|
||||
subItems: [
|
||||
{ name: "Articulos", path: "/inventario/articulos" },
|
||||
{ name: "Lotes", path: "/inventario/lotes" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "2. Socio de negocios",
|
||||
icon: "👥",
|
||||
subItems: [
|
||||
{ name: "Clientes", path: "/socios/clientes" },
|
||||
{ name: "Proveedores", path: "/socios/proveedores" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "3. Ventas",
|
||||
icon: "🛒",
|
||||
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: "💼",
|
||||
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: "📊",
|
||||
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: "📊",
|
||||
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: "🏷️",
|
||||
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}
|
||||
>
|
||||
<span className="text-sm">{module.icon}</span>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user