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
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react';
import type { ToastPayload, ToastKind } from '@/lib/toast';
type Item = ToastPayload & { id: number; leaving?: boolean };
const KIND_META: Record<ToastKind, { color: string; d: string }> = {
success: {
color: 'var(--color-primary)',
d: 'M4.5 12.75l6 6 9-13.5',
},
error: {
color: 'var(--color-danger)',
d: 'M6 18L18 6M6 6l12 12',
},
info: {
color: 'var(--color-secondary)',
d: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
},
};
export default function Toaster() {
const [items, setItems] = useState<Item[]>([]);
const nextId = useRef(1);
useEffect(() => {
function push(payload: ToastPayload) {
const id = nextId.current++;
const duration = payload.duration ?? 3800;
setItems((prev) => [...prev, { ...payload, id, kind: payload.kind ?? 'info' }]);
setTimeout(() => dismiss(id, true), duration);
}
function onToast(e: Event) {
push((e as CustomEvent<ToastPayload>).detail);
}
window.addEventListener('labre:toast', onToast);
// Drenar toasts pendientes de un reload previo.
try {
const raw = sessionStorage.getItem('labre:toast:queue');
if (raw) {
sessionStorage.removeItem('labre:toast:queue');
(JSON.parse(raw) as ToastPayload[]).forEach(push);
}
} catch {
/* noop */
}
return () => window.removeEventListener('labre:toast', onToast);
}, []);
function dismiss(id: number, animate = false) {
if (animate) {
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, leaving: true } : it)));
setTimeout(() => setItems((prev) => prev.filter((it) => it.id !== id)), 250);
} else {
setItems((prev) => prev.filter((it) => it.id !== id));
}
}
return (
<div
role="region"
aria-label="Notificaciones"
className="fixed z-[60] left-1/2 -translate-x-1/2 top-4 md:top-6 w-[min(92vw,26rem)] flex flex-col items-stretch gap-2 pointer-events-none"
>
{items.map((t) => {
const meta = KIND_META[t.kind ?? 'info'];
return (
<button
key={t.id}
type="button"
onClick={() => dismiss(t.id, true)}
aria-live="polite"
className={
'toast-item pointer-events-auto text-left flex items-start gap-3 rounded-2xl px-4 py-3 ' +
'bg-white/85 backdrop-blur-xl border shadow-[0_16px_40px_-12px_rgba(0,0,0,0.25)] ' +
'motion-safe:transition-[opacity,transform] motion-safe:duration-200 ' +
(t.leaving ? 'toast-leave' : 'toast-enter')
}
style={{
borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)',
color: 'var(--color-ink)',
}}
>
<span
aria-hidden="true"
className="shrink-0 grid place-items-center w-9 h-9 rounded-full text-white"
style={{ background: meta.color }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d={meta.d} />
</svg>
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-sm leading-snug">{t.title}</span>
{t.description && (
<span className="block text-xs opacity-70 mt-0.5 leading-snug">{t.description}</span>
)}
</span>
</button>
);
})}
<style>{`
.toast-enter {
animation: toast-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toast-leave {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.toast-enter { animation: none; }
}
`}</style>
</div>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
type Prestamo = {
id: number;
@@ -62,9 +63,16 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
toastAfterReload({
kind: 'success',
title: 'Solicitud aprobada',
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
});
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al aprobar.');
const msg = err.message ?? 'Error al aprobar.';
setError(msg);
toast({ kind: 'error', title: 'No se pudo aprobar', description: msg });
setLoading(false);
}
};
@@ -147,9 +155,16 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
toastAfterReload({
kind: 'info',
title: 'Solicitud rechazada',
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
});
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al rechazar.');
const msg = err.message ?? 'Error al rechazar.';
setError(msg);
toast({ kind: 'error', title: 'No se pudo rechazar', description: msg });
setLoading(false);
}
};
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestamoId: number; nombreMaterial: string }) {
const ref = useRef<HTMLDialogElement>(null);
@@ -23,9 +24,16 @@ export default function MarcarDevuelto({ prestamoId, nombreMaterial }: { prestam
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Error ${res.status}`);
}
toastAfterReload({
kind: 'success',
title: 'Devolución registrada',
description: `${nombreMaterial} regresa al inventario`,
});
location.reload();
} catch (err: any) {
setError(err.message ?? 'Error al registrar la devolución.');
const msg = err.message ?? 'Error al registrar la devolución.';
setError(msg);
toast({ kind: 'error', title: 'No se pudo registrar', description: msg });
setLoading(false);
}
};
+16 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
type Props = {
material: {
@@ -60,12 +61,19 @@ export default function SolicitarModal({ material }: Props) {
throw new Error(json?.error ?? 'No se pudo enviar la solicitud');
}
setOk(true);
toastAfterReload({
kind: 'success',
title: 'Solicitud enviada',
description: `${material.nombre} · pendiente de aprobación`,
});
setTimeout(() => {
dialogRef.current?.close();
location.reload();
}, 800);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
const msg = err instanceof Error ? err.message : 'Error inesperado';
setError(msg);
toast({ kind: 'error', title: 'No se pudo enviar', description: msg });
} finally {
setLoading(false);
}
@@ -126,17 +134,22 @@ export default function SolicitarModal({ material }: Props) {
</div>
<div>
<label className="label" htmlFor={`notas-${material.id}`}>Notas (opcional)</label>
<label className="label" htmlFor={`notas-${material.id}`}>Motivo del préstamo</label>
<textarea
id={`notas-${material.id}`}
className="input"
rows={3}
maxLength={500}
value={notas}
required
onChange={(e) => setNotas(e.target.value)}
placeholder="Motivo, materia, fecha estimada de devolución…"
placeholder="¿Para qué clase o proyecto lo necesitas?"
style={{ minHeight: '96px' }}
aria-describedby={`notas-hint-${material.id}`}
/>
<p id={`notas-hint-${material.id}`} className="text-xs opacity-60 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3.
</p>
</div>
{error && (