UX: motivo requerido, dock móvil, hamburguesa desktop, toaster Sileo, panel de estadísticas

Alumno:
- Modal de solicitud: campo "Notas" renombrado a "Motivo del préstamo",
  ahora requerido, con hint sobre uso (para qué clase o proyecto).

Admin:
- Bandeja de solicitudes pendientes, activos y devoluciones ahora
  muestran el motivo del alumno (en tabla desktop y tarjetas móvil).
- /admin/inventario se convirtió en "Estadísticas": grid de KPIs del
  día (solicitadas, aprobadas, rechazadas, devoluciones, en préstamo,
  vencidos) arriba del inventario existente. Los cambios de estado se
  cuentan desde audit_log, el rango del día se calcula automáticamente
  en timezone America/Tijuana con DST via Intl API (src/lib/date.ts).

Rediseño móvil:
- Sidebar desktop se puede colapsar con botón hamburguesa. Estado
  persiste en localStorage sin flash (script is:inline bloqueante).
- Nav móvil reemplazado por dock píldora flotante con iconos SVG
  inline (heroicons outline). Respeta safe-area-inset-bottom.

Notificaciones estilo Sileo:
- Componente Toaster global (React island client:idle) que escucha
  CustomEvents. Toasts con backdrop-blur, animación de entrada
  translateY+opacity, auto-dismiss 3.8s, click para cerrar. Helper
  toastAfterReload deja pendiente en sessionStorage para sobrevivir
  location.reload() post-acción.

Diagnóstico:
- /api/whoami devuelve user+profile actuales para depurar sesión.
- Endpoint devolver retorna un objeto `debug` en el 403 con
  email+rol vistos por el server (temporal, para trazar bug del 403).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 16:47:30 -07:00
parent 5063a4b08d
commit a96ff4e41f
13 changed files with 498 additions and 54 deletions
+161 -43
View File
@@ -1,5 +1,6 @@
---
import Layout from './Layout.astro';
import Toaster from '@/components/Toaster.tsx';
interface Props {
title?: string;
@@ -10,72 +11,189 @@ const profile = Astro.locals.profile;
const isAdmin = profile?.rol === 'admin';
const path = Astro.url.pathname;
const isActive = (href: string) => path === href || path.startsWith(href + '/');
type NavItem = { href: string; label: string; icon: string };
// Iconos: paths de heroicons/lucide outline (24x24). Solo el `d` del <path>.
const ICONS = {
menu: 'M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5',
stats: 'M2.25 18L9 11.25l4.306 4.306a11.95 11.95 0 015.814-5.518l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941',
home: 'M2.25 12l8.954-8.955a1.5 1.5 0 012.122 0L22.28 12M4.5 9.75v10.125A1.125 1.125 0 005.625 21H9.75v-6h4.5v6h4.125A1.125 1.125 0 0019.5 19.875V9.75',
grid: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 8.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z',
list: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z',
inbox: 'M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H6.911a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661z',
box: 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5H7.688C5.098 3.75 3 5.765 3 8.25v7.5c0 2.485 2.099 4.5 4.688 4.5h8.624c2.59 0 4.688-2.015 4.688-4.5v-7.5zM3 8.25l9 5.25 9-5.25',
chart: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z',
logout: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75',
dashboard: 'M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605',
};
const alumnoNav: NavItem[] = [
{ href: '/', label: 'Inicio', icon: 'home' },
{ href: '/alumno/catalogo', label: 'Catálogo', icon: 'grid' },
{ href: '/alumno/mis-prestamos', label: 'Mis préstamos', icon: 'list' },
];
const adminNav: NavItem[] = [
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
{ href: '/admin/inventario', label: 'Estadísticas', icon: 'stats' },
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
];
const nav = isAdmin ? adminNav : alumnoNav;
const activeCheck = (item: NavItem) => {
if (item.href === '/') return path === '/';
if (item.href === '/admin') return path === '/admin';
return isActive(item.href);
};
---
<Layout title={title}>
<div class="min-h-screen flex flex-col md:flex-row">
<!-- Sidebar (desktop) / topbar (mobile) -->
<aside class="md:w-64 md:min-h-screen bg-[color:var(--color-primary)] text-white flex md:flex-col">
<a href="/" class="p-4 md:p-6 flex items-center gap-3 md:border-b border-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="LabPréstamos — Inicio">
<!-- No-flash: aplica estado colapsado del sidebar antes de pintar -->
<script is:inline>
try {
if (localStorage.getItem('sidebar') === 'collapsed') {
document.documentElement.classList.add('sidebar-collapsed');
}
} catch (_) {}
</script>
<div class="min-h-screen flex flex-col md:block">
<!-- Sidebar desktop -->
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-[color:var(--color-primary)] text-white md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out">
<a href="/" class="p-6 flex items-center gap-3 border-b border-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="LabPréstamos — Inicio">
<div class="w-9 h-9 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold" aria-hidden="true">L</div>
<div class="hidden md:block">
<div>
<div class="font-semibold leading-tight">LabPréstamos</div>
<div class="text-xs text-white/70">UABC · Lab. Sistemas</div>
</div>
</a>
<nav class="flex-1 flex md:flex-col md:p-3 gap-1 overflow-x-auto md:overflow-visible" aria-label="Navegación principal">
{isAdmin ? (
<>
<a href="/admin" class:list={["nav-item", isActive('/admin') && !path.startsWith('/admin/') && "nav-item-active"]}>Panel</a>
<a href="/admin/solicitudes" class:list={["nav-item", isActive('/admin/solicitudes') && "nav-item-active"]}>Solicitudes</a>
<a href="/admin/inventario" class:list={["nav-item", isActive('/admin/inventario') && "nav-item-active"]}>Inventario</a>
<a href="/admin/reportes" class:list={["nav-item", isActive('/admin/reportes') && "nav-item-active"]}>Reportes</a>
</>
) : (
<>
<a href="/" class:list={["nav-item", path === '/' && "nav-item-active"]}>Inicio</a>
<a href="/alumno/catalogo" class:list={["nav-item", isActive('/alumno/catalogo') && "nav-item-active"]}>Catálogo</a>
<a href="/alumno/mis-prestamos" class:list={["nav-item", isActive('/alumno/mis-prestamos') && "nav-item-active"]}>Mis préstamos</a>
</>
)}
<nav class="flex-1 p-3 flex flex-col gap-1" aria-label="Navegación principal">
{nav.map((item) => (
<a
href={item.href}
class:list={[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
activeCheck(item)
? 'bg-white/15 text-white'
: 'text-white/85 hover:bg-white/10 hover:text-white'
]}
aria-current={activeCheck(item) ? 'page' : undefined}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
{item.label}
</a>
))}
</nav>
<div class="hidden md:block p-3 border-t border-white/10">
<div class="p-3 border-t border-white/10">
<div class="px-3 py-2 text-sm">
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-white/70 text-xs capitalize">{profile?.rol}</div>
</div>
<form method="POST" action="/api/auth/signout">
<button type="submit" class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-white/10 transition-colors">
<button type="submit" class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-white/10 transition-colors flex items-center gap-2">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS.logout} />
</svg>
Cerrar sesión
</button>
</form>
</div>
<!-- Mobile signout -->
<form method="POST" action="/api/auth/signout" class="md:hidden ml-auto p-2">
<button type="submit" aria-label="Cerrar sesión" class="p-2 rounded-lg hover:bg-white/10 transition-colors">
<span aria-hidden="true">↩</span>
</button>
</form>
</aside>
<main id="main" class="flex-1 p-4 md:p-8">
<!-- Header mobile mínimo -->
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-[color:var(--color-primary)] text-white">
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white rounded-lg" aria-label="LabPréstamos — Inicio">
<div class="w-8 h-8 rounded-lg bg-[color:var(--color-secondary)] grid place-items-center font-bold text-sm" aria-hidden="true">L</div>
<span class="font-semibold text-sm">LabPréstamos</span>
</a>
<div class="text-xs text-white/80 truncate max-w-[45%] text-right">
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
<div class="text-white/60 capitalize">{profile?.rol}</div>
</div>
</header>
<main id="main" class="flex-1 p-4 md:p-8 pb-28 md:pb-8 md:ml-64 sidebar-aware transition-[margin] duration-200 ease-out">
<!-- Botón hamburguesa desktop: toggle sidebar -->
<button
type="button"
id="sidebar-toggle"
aria-label="Alternar menú lateral"
aria-controls="app-sidebar"
class="hidden md:inline-flex items-center justify-center w-10 h-10 rounded-lg mb-4 text-[color:var(--color-ink)]/80 hover:bg-[color:var(--color-ink)]/5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)]"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS.menu} />
</svg>
</button>
<slot />
</main>
<!-- Dock flotante mobile -->
<nav
class="md:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-50 rounded-full px-2 py-2 flex items-center gap-1 backdrop-blur bg-white/95 shadow-[0_10px_30px_-8px_rgba(0,0,0,0.25)] border"
style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));"
aria-label="Navegación principal"
>
{nav.map((item) => {
const active = activeCheck(item);
return (
<a
href={item.href}
aria-label={item.label}
aria-current={active ? 'page' : undefined}
class:list={[
'grid place-items-center rounded-full w-11 h-11 transition-[background-color,color,transform] duration-150 motion-safe:active:scale-95',
active
? 'bg-[color:var(--color-primary)] text-white'
: 'text-[color:var(--color-ink)]/70 hover:text-[color:var(--color-ink)] hover:bg-[color:var(--color-ink)]/5'
]}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[item.icon as keyof typeof ICONS]} />
</svg>
</a>
);
})}
<span class="w-px h-6 mx-1" style="background: color-mix(in oklab, var(--color-ink) 15%, transparent);" aria-hidden="true"></span>
<form method="POST" action="/api/auth/signout" class="contents">
<button
type="submit"
aria-label="Cerrar sesión"
class="grid place-items-center rounded-full w-11 h-11 text-[color:var(--color-ink)]/70 hover:text-[color:var(--color-danger)] hover:bg-[color:var(--color-danger)]/10 transition-[background-color,color,transform] duration-150 motion-safe:active:scale-95"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS.logout} />
</svg>
</button>
</form>
</nav>
<Toaster client:idle />
</div>
</Layout>
<style>
@reference "tailwindcss";
.nav-item {
@apply block px-3 py-2 md:py-2.5 rounded-lg text-sm font-medium text-white/85
whitespace-nowrap transition-colors duration-150
hover:bg-white/10 hover:text-white
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white;
}
.nav-item-active {
background: rgba(255, 255, 255, 0.15);
color: white;
<style is:global>
@media (min-width: 768px) {
.sidebar-collapsed .sidebar-panel { transform: translateX(-100%); }
.sidebar-collapsed .sidebar-aware { margin-left: 0; }
}
</style>
<script>
const btn = document.getElementById('sidebar-toggle');
const root = document.documentElement;
const sync = () => {
const collapsed = root.classList.contains('sidebar-collapsed');
btn?.setAttribute('aria-expanded', String(!collapsed));
};
sync();
btn?.addEventListener('click', () => {
root.classList.toggle('sidebar-collapsed');
const collapsed = root.classList.contains('sidebar-collapsed');
try { localStorage.setItem('sidebar', collapsed ? 'collapsed' : 'open'); } catch (_) {}
sync();
});
</script>