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
+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 && (