Files
PDV/apps/web/src/app/admin/_components/admin-shell.tsx
T
2026-09-30 14:45:10 -05:00

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>
);
}