210 lines
8.4 KiB
TypeScript
210 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { Icon } from "@iconify/react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
iconArchive,
|
|
iconChart,
|
|
iconCloseSquare,
|
|
iconDashboard,
|
|
iconDelivery,
|
|
iconHamburgerMenu,
|
|
iconStorefrontFill,
|
|
iconUserPlus,
|
|
iconUsersGroup,
|
|
iconWalletMoney,
|
|
} from "../../_core/icons";
|
|
import { useSessionUser } from "../../_core/hooks/use-session-user";
|
|
import { OperatingDayControl } from "./operating-day-control";
|
|
import { UserMenu } from "./user-menu";
|
|
|
|
const NAV_ITEMS = [
|
|
{ href: "/admin", label: "Panel", icon: iconDashboard },
|
|
{ href: "/admin/inventario", label: "Inventario", icon: iconArchive },
|
|
{ href: "/admin/proveedores", label: "Proveedores", icon: iconDelivery },
|
|
{ href: "/admin/clientes", label: "Clientes", icon: iconUserPlus },
|
|
{ href: "/admin/reportes", label: "Reportes", icon: iconChart },
|
|
{ href: "/admin/usuarios", label: "Usuarios", icon: iconUsersGroup },
|
|
] as const;
|
|
|
|
function isActive(pathname: string, href: string) {
|
|
if (href === "/admin") return pathname === "/admin";
|
|
return pathname.startsWith(href);
|
|
}
|
|
|
|
export function AdminShell({ children }: { children: React.ReactNode }) {
|
|
const pathname = usePathname();
|
|
const { user, logout } = useSessionUser();
|
|
const reduceMotion = useReducedMotion();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [prevPathname, setPrevPathname] = useState(pathname);
|
|
|
|
if (pathname !== prevPathname) {
|
|
setPrevPathname(pathname);
|
|
setMenuOpen(false);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!menuOpen) return;
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") setMenuOpen(false);
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
}, [menuOpen]);
|
|
|
|
if (!user) return null;
|
|
|
|
const activeItem = NAV_ITEMS.find((item) => isActive(pathname, item.href));
|
|
|
|
const navList = (
|
|
<nav aria-label="Principal" className="flex flex-1 flex-col gap-1 overflow-y-auto p-3">
|
|
<span className="px-3 pb-1.5 pt-1 text-[11px] font-semibold tracking-wider text-muted uppercase">
|
|
Menú principal
|
|
</span>
|
|
{NAV_ITEMS.map((item) => {
|
|
const active = isActive(pathname, item.href);
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
aria-current={active ? "page" : undefined}
|
|
className={`group relative flex items-center gap-3 rounded-lg py-2.5 pl-3.5 pr-3 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ${
|
|
active
|
|
? "bg-accent-soft text-accent-soft-foreground"
|
|
: "text-muted hover:bg-foreground/5 hover:text-foreground"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`absolute inset-y-1.5 left-0 w-[3px] rounded-full transition-opacity ${
|
|
active ? "bg-accent opacity-100" : "opacity-0"
|
|
}`}
|
|
aria-hidden="true"
|
|
/>
|
|
<span
|
|
className={`flex size-8 shrink-0 items-center justify-center rounded-md transition ${
|
|
active ? "bg-accent text-accent-foreground" : "bg-foreground/5 text-muted group-hover:text-foreground"
|
|
}`}
|
|
>
|
|
<Icon icon={item.icon} width={17} height={17} aria-hidden="true" />
|
|
</span>
|
|
{item.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-dvh w-full flex-col gap-3 overflow-hidden bg-background p-3 text-foreground md:p-4">
|
|
{/* Unified header */}
|
|
<header className="flex h-16 shrink-0 items-center gap-3 rounded-2xl border border-border bg-surface px-3 md:px-5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMenuOpen(true)}
|
|
aria-label="Abrir menú"
|
|
aria-expanded={menuOpen}
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-lg text-foreground transition hover:bg-foreground/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus md:hidden"
|
|
>
|
|
<Icon icon={iconHamburgerMenu} width={22} height={22} aria-hidden="true" />
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-accent-soft">
|
|
<Icon icon={iconStorefrontFill} width={20} height={20} className="text-accent-soft-foreground" aria-hidden="true" />
|
|
</div>
|
|
<div className="hidden min-w-0 leading-tight sm:block">
|
|
<p className="truncate text-sm font-semibold tracking-wide text-foreground">PDV</p>
|
|
<p className="truncate text-[11px] text-muted">Panel administrativo</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mx-2 hidden h-8 w-px shrink-0 bg-border md:block" />
|
|
|
|
<h1 className="hidden min-w-0 truncate text-base font-semibold text-foreground md:block">
|
|
{activeItem?.label ?? "Panel"}
|
|
</h1>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<Link
|
|
href="/pos"
|
|
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm font-medium text-muted transition hover:bg-foreground/5 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
|
|
>
|
|
<Icon icon={iconWalletMoney} width={16} height={16} aria-hidden="true" />
|
|
<span className="hidden sm:inline">Ir a Caja</span>
|
|
</Link>
|
|
<OperatingDayControl />
|
|
<UserMenu user={user} onLogout={logout} />
|
|
</header>
|
|
|
|
<div className="flex flex-1 gap-3 overflow-hidden">
|
|
{/* Sidebar (desktop) */}
|
|
<aside className="hidden w-64 shrink-0 flex-col rounded-2xl border border-border bg-surface md:flex">
|
|
{navList}
|
|
</aside>
|
|
|
|
<main className="flex-1 overflow-y-auto rounded-2xl border border-border bg-surface p-4 md:p-8">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
|
|
{/* Mobile drawer */}
|
|
<AnimatePresence>
|
|
{menuOpen && (
|
|
<>
|
|
<motion.div
|
|
key="backdrop"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: reduceMotion ? 0 : 0.15 }}
|
|
className="fixed inset-0 z-40 bg-backdrop md:hidden"
|
|
onClick={() => setMenuOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
<motion.div
|
|
key="drawer"
|
|
initial={{ x: reduceMotion ? 0 : "-100%" }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: reduceMotion ? 0 : "-100%" }}
|
|
transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Menú principal"
|
|
className="fixed inset-y-3 left-3 z-50 flex w-72 flex-col rounded-2xl border border-border bg-surface md:hidden"
|
|
>
|
|
<div className="flex items-center justify-between border-b border-border px-4 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex size-9 items-center justify-center rounded-xl bg-accent-soft">
|
|
<Icon
|
|
icon={iconStorefrontFill}
|
|
width={20}
|
|
height={20}
|
|
className="text-accent-soft-foreground"
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
<span className="text-sm font-semibold tracking-wide text-foreground">PDV Admin</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMenuOpen(false)}
|
|
aria-label="Cerrar menú"
|
|
className="flex size-9 items-center justify-center rounded-lg text-muted transition hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus"
|
|
>
|
|
<Icon icon={iconCloseSquare} width={20} height={20} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
{navList}
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|