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