From a96ff4e41febdbf33cb19840fb531e3fd11b2369 Mon Sep 17 00:00:00 2001 From: Lak-G Date: Sat, 15 Aug 2026 16:47:30 -0700 Subject: [PATCH] =?UTF-8?q?UX:=20motivo=20requerido,=20dock=20m=C3=B3vil,?= =?UTF-8?q?=20hamburguesa=20desktop,=20toaster=20Sileo,=20panel=20de=20est?= =?UTF-8?q?ad=C3=ADsticas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/Toaster.tsx | 121 +++++++++++ .../admin/solicitudes/AccionesSolicitud.tsx | 19 +- .../admin/solicitudes/MarcarDevuelto.tsx | 10 +- src/components/alumno/SolicitarModal.tsx | 19 +- src/layouts/AppLayout.astro | 204 ++++++++++++++---- src/lib/date.ts | 39 ++++ src/lib/supabase.ts | 11 + src/lib/toast.ts | 25 +++ src/pages/admin/inventario/index.astro | 66 +++++- src/pages/admin/solicitudes/activos.astro | 10 + src/pages/admin/solicitudes/index.astro | 10 + .../api/admin/prestamos/[id]/devolver.ts | 7 +- src/pages/api/whoami.ts | 11 + 13 files changed, 498 insertions(+), 54 deletions(-) create mode 100644 src/components/Toaster.tsx create mode 100644 src/lib/date.ts create mode 100644 src/lib/toast.ts create mode 100644 src/pages/api/whoami.ts diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx new file mode 100644 index 0000000..1c0bd50 --- /dev/null +++ b/src/components/Toaster.tsx @@ -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 = { + 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([]); + 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).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 ( +
+ {items.map((t) => { + const meta = KIND_META[t.kind ?? 'info']; + return ( + + ); + })} + + +
+ ); +} diff --git a/src/components/admin/solicitudes/AccionesSolicitud.tsx b/src/components/admin/solicitudes/AccionesSolicitud.tsx index 7be28b6..39a69d2 100644 --- a/src/components/admin/solicitudes/AccionesSolicitud.tsx +++ b/src/components/admin/solicitudes/AccionesSolicitud.tsx @@ -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); } }; diff --git a/src/components/admin/solicitudes/MarcarDevuelto.tsx b/src/components/admin/solicitudes/MarcarDevuelto.tsx index 9a320d2..6d1aad3 100644 --- a/src/components/admin/solicitudes/MarcarDevuelto.tsx +++ b/src/components/admin/solicitudes/MarcarDevuelto.tsx @@ -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(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); } }; diff --git a/src/components/alumno/SolicitarModal.tsx b/src/components/alumno/SolicitarModal.tsx index d9c4e94..e8f037b 100644 --- a/src/components/alumno/SolicitarModal.tsx +++ b/src/components/alumno/SolicitarModal.tsx @@ -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) {
- +