Files
labre-web/src/pages/admin/inventario/index.astro
T
LakG a96ff4e41f 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>
2026-08-15 16:47:30 -07:00

299 lines
13 KiB
Plaintext

---
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;
nombre: string;
descripcion: string | null;
cantidad_total: number;
cantidad_disponible: number;
numero_inventario: string | null;
estado: 'disponible' | 'mantenimiento' | 'baja';
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;
const estadoRaw = Astro.url.searchParams.get('estado') ?? '';
const estado = ['disponible', 'mantenimiento', 'baja'].includes(estadoRaw) ? estadoRaw : '';
let query = Astro.locals.supabase
.from('materiales')
.select(
'id, nombre, descripcion, cantidad_total, cantidad_disponible, numero_inventario, estado, categoria:categorias(id, nombre)',
);
if (q) query = query.or(`nombre.ilike.%${q}%,numero_inventario.ilike.%${q}%`);
if (catId != null) query = query.eq('categoria_id', catId);
if (estado) query = query.eq('estado', estado);
const { data: matData, error: matError } = await query.order('nombre');
const materiales = (matData ?? []) as unknown as Material[];
const { data: catData } = await Astro.locals.supabase
.from('categorias')
.select('id, nombre')
.order('nombre');
const categorias = (catData ?? []) as { id: number; nombre: string }[];
const estadoBadge = (e: Material['estado']) => {
if (e === 'disponible')
return {
label: 'Disponible',
style: 'background: color-mix(in oklab, var(--color-primary) 12%, transparent); color: var(--color-primary);',
};
if (e === 'mantenimiento')
return {
label: 'Mantenimiento',
style: 'background: color-mix(in oklab, var(--color-secondary) 15%, transparent); color: var(--color-secondary-hover);',
};
return {
label: 'Baja',
style: 'background: color-mix(in oklab, var(--color-ink) 8%, transparent); color: color-mix(in oklab, var(--color-ink) 60%, transparent);',
};
};
---
<AppLayout title="Estadísticas — LabPréstamos">
<div class="max-w-6xl">
<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">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
</a>
<a href="/admin/inventario/categorias" class="px-4 py-2 text-sm font-medium border-b-2 border-transparent opacity-70 transition-colors duration-150 hover:opacity-100">
Categorías
</a>
</nav>
<form method="GET" class="card mb-6 grid grid-cols-1 sm:grid-cols-4 gap-3" role="search">
<div class="sm:col-span-2">
<label class="label" for="filtro-q">Buscar</label>
<input id="filtro-q" name="q" type="search" class="input" value={q} placeholder="Nombre o Nº inventario…" autocomplete="off" />
</div>
<div>
<label class="label" for="filtro-cat">Categoría</label>
<select id="filtro-cat" name="cat" class="input">
<option value="">Todas</option>
{categorias.map((c) => (
<option value={String(c.id)} selected={catId === c.id}>{c.nombre}</option>
))}
</select>
</div>
<div>
<label class="label" for="filtro-estado">Estado</label>
<select id="filtro-estado" name="estado" class="input">
<option value="">Todos</option>
<option value="disponible" selected={estado === 'disponible'}>Disponible</option>
<option value="mantenimiento" selected={estado === 'mantenimiento'}>Mantenimiento</option>
<option value="baja" selected={estado === 'baja'}>Baja</option>
</select>
</div>
<div class="sm:col-span-4 flex gap-2 justify-end">
{(q || catId != null || estado) && (
<a href="/admin/inventario" class="btn btn-ghost">Limpiar</a>
)}
<button type="submit" class="btn btn-primary">Filtrar</button>
</div>
</form>
{matError && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger);">
No se pudo cargar el inventario. Recarga la página.
</p>
</div>
)}
{!matError && materiales.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin resultados</h2>
<p class="text-sm opacity-70">
{q || catId != null || estado
? 'Ningún material coincide con los filtros. Ajusta la búsqueda.'
: 'Aún no hay material registrado. Agrega uno para comenzar.'}
</p>
</div>
)}
{!matError && materiales.length > 0 && (
<>
<div class="hidden md:block card p-0 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium">Categoría</th>
<th class="px-4 py-3 font-medium">Nº inventario</th>
<th class="px-4 py-3 font-medium text-right">Stock</th>
<th class="px-4 py-3 font-medium">Estado</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{materiales.map((m) => {
const badge = estadoBadge(m.estado);
return (
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3">
<div class="font-medium">{m.nombre}</div>
{m.descripcion && (
<div class="text-xs opacity-60 line-clamp-1">{m.descripcion}</div>
)}
</td>
<td class="px-4 py-3 opacity-80">{m.categoria?.nombre ?? '—'}</td>
<td class="px-4 py-3 font-mono text-xs opacity-80">{m.numero_inventario ?? '—'}</td>
<td class="px-4 py-3 text-right" style="font-variant-numeric: tabular-nums;">
{m.cantidad_disponible} / {m.cantidad_total}
</td>
<td class="px-4 py-3">
<span class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" style={badge.style}>
{badge.label}
</span>
</td>
<td class="px-4 py-3">
<div class="flex gap-2 justify-end">
<MaterialForm
mode="edit"
material={{
id: m.id,
nombre: m.nombre,
categoria_id: m.categoria?.id ?? null,
descripcion: m.descripcion,
cantidad_total: m.cantidad_total,
cantidad_disponible: m.cantidad_disponible,
numero_inventario: m.numero_inventario,
estado: m.estado,
}}
categorias={categorias}
client:load
/>
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div class="md:hidden flex flex-col gap-3">
{materiales.map((m) => {
const badge = estadoBadge(m.estado);
return (
<article class="card flex flex-col gap-3">
<div class="flex items-start justify-between gap-2">
<div>
<h3 class="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && (
<p class="text-xs opacity-70 mt-0.5">{m.categoria.nombre}</p>
)}
</div>
<span class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" style={badge.style}>
{badge.label}
</span>
</div>
{m.descripcion && <p class="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
<dl class="text-xs opacity-70 grid grid-cols-2 gap-1">
<dt class="opacity-60">Inventario</dt>
<dd class="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt class="opacity-60">Stock</dt>
<dd class="text-right" style="font-variant-numeric: tabular-nums;">
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div class="flex gap-2 justify-end mt-auto">
<MaterialForm
mode="edit"
material={{
id: m.id,
nombre: m.nombre,
categoria_id: m.categoria?.id ?? null,
descripcion: m.descripcion,
cantidad_total: m.cantidad_total,
cantidad_disponible: m.cantidad_disponible,
numero_inventario: m.numero_inventario,
estado: m.estado,
}}
categorias={categorias}
client:load
/>
<EliminarMaterial id={m.id} nombre={m.nombre} client:load />
</div>
</article>
);
})}
</div>
</>
)}
</div>
</AppLayout>