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:
@@ -2,6 +2,7 @@
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import MaterialForm from '@/components/admin/inventario/MaterialForm.tsx';
|
||||
import EliminarMaterial from '@/components/admin/inventario/EliminarMaterial.tsx';
|
||||
import { todayMX } from '@/lib/date';
|
||||
|
||||
type Material = {
|
||||
id: number;
|
||||
@@ -14,6 +15,45 @@ type Material = {
|
||||
categoria: { id: number; nombre: string } | null;
|
||||
};
|
||||
|
||||
// KPIs del día (fecha automática, timezone MX con DST)
|
||||
const dia = todayMX();
|
||||
const supabase = Astro.locals.supabase;
|
||||
const countHead = { count: 'exact' as const, head: true };
|
||||
const startISO = dia.startUTC.toISOString();
|
||||
const endISO = dia.endUTC.toISOString();
|
||||
|
||||
const [
|
||||
{ count: nSolicitadas },
|
||||
{ count: nAprobadas },
|
||||
{ count: nRechazadas },
|
||||
{ count: nDevueltas },
|
||||
{ count: nEnPrestamo },
|
||||
{ count: nVencidos },
|
||||
] = await Promise.all([
|
||||
supabase.from('prestamos').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('prestamos').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('prestamos').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
]);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
||||
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
||||
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
||||
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
||||
];
|
||||
|
||||
const toneStyle = (tone: string) => {
|
||||
if (tone === 'primary') return 'color: var(--color-primary);';
|
||||
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
||||
if (tone === 'danger') return 'color: var(--color-danger);';
|
||||
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
||||
};
|
||||
|
||||
const q = (Astro.url.searchParams.get('q') ?? '').trim();
|
||||
const catRaw = Astro.url.searchParams.get('cat') ?? '';
|
||||
const catId = catRaw && /^\d+$/.test(catRaw) ? Number(catRaw) : null;
|
||||
@@ -56,16 +96,34 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
};
|
||||
};
|
||||
---
|
||||
<AppLayout title="Inventario — Admin">
|
||||
<AppLayout title="Estadísticas — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
|
||||
<header class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Inventario</h1>
|
||||
<p class="text-sm opacity-70 mt-1">Materiales del laboratorio.</p>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
||||
<p class="text-sm opacity-70 mt-1 first-letter:uppercase">
|
||||
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||
</p>
|
||||
</div>
|
||||
<MaterialForm mode="create" categorias={categorias} client:load />
|
||||
</header>
|
||||
|
||||
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||
{kpis.map((k) => (
|
||||
<div class="card p-4">
|
||||
<div class="text-xs opacity-70">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
||||
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
||||
{k.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold">Inventario</h2>
|
||||
<p class="text-sm opacity-70">Materiales del laboratorio.</p>
|
||||
</div>
|
||||
|
||||
<nav class="flex gap-2 mb-6 border-b" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);" aria-label="Sub-navegación de inventario">
|
||||
<a href="/admin/inventario" class="px-4 py-2 text-sm font-medium border-b-2" style="border-color: var(--color-primary); color: var(--color-primary);">
|
||||
Materiales
|
||||
|
||||
Reference in New Issue
Block a user