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:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user