81a5ea016e
El vale físico del LSC permite pedir varios materiales en un solo trámite; el sistema modelaba 1 solicitud = 1 material. Migra prestamos.prestamos -> solicitudes (cabecera) + solicitud_items (renglones), vía RENAME + backfill (preserva ids/historial real). - RPC prestamos.crear_solicitud: transaccional, lockea materiales por fila, arregla la race condition del insert directo anterior. El alumno ya no inserta directo (RLS lo bloquea). - sync_stock/log_estado reescritos para iterar renglones por vale. - maestro_responsable (por vale) y profiles.semestre (perfil, nullable, sin UI todavía — onboarding queda para después). - Alumno: carrito (SolicitudCart/AgregarMaterial) reemplaza el modal de solicitud único por material. - Admin: 3 vistas de solicitudes, reportes y CSV export listan renglones por vale; api/admin/prestamos -> api/admin/solicitudes. De paso corrige un bug preexistente en detalles.ts (audit_log nunca se ordenaba por la columna correcta). Verificado: build limpio, ciclo RPC+triggers probado en una transacción revertida en prod (sin residuo), 9 vistas SSR smoke-testeadas contra el schema real. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
98 lines
4.3 KiB
Plaintext
98 lines
4.3 KiB
Plaintext
---
|
||
import AppLayout from '@/layouts/AppLayout.astro';
|
||
|
||
const supabase = Astro.locals.supabase;
|
||
const today = new Date().toISOString().split('T')[0];
|
||
|
||
const [pend, activos, vencidos, agotados, ultimas] = await Promise.all([
|
||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).eq('estado', 'pendiente'),
|
||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']),
|
||
supabase.from('solicitudes').select('*', { count: 'exact', head: true }).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', today),
|
||
supabase.from('materiales').select('*', { count: 'exact', head: true }).eq('cantidad_disponible', 0),
|
||
supabase
|
||
.from('solicitudes')
|
||
.select('id, fecha_solicitud, estado, alumno:profiles!alumno_id(nombre, email), items:solicitud_items(cantidad, material:materiales(nombre))')
|
||
.order('fecha_solicitud', { ascending: false })
|
||
.limit(5),
|
||
]);
|
||
|
||
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||
|
||
const kpis = [
|
||
{ label: 'Pendientes', value: pend.count ?? 0, href: '/admin/solicitudes', tone: 'primary' },
|
||
{ label: 'En préstamo', value: activos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'secondary' },
|
||
{ label: 'Vencidos', value: vencidos.count ?? 0, href: '/admin/solicitudes/activos', tone: 'danger' },
|
||
{ label: 'Agotados', value: agotados.count ?? 0, href: '/admin/inventario', tone: 'ink' },
|
||
];
|
||
|
||
const estadoLabel: Record<string, string> = {
|
||
pendiente: 'Pendiente',
|
||
aprobado: 'En préstamo',
|
||
activo: 'En préstamo',
|
||
devuelto: 'Devuelto',
|
||
rechazado: 'Rechazado',
|
||
vencido: 'Vencido',
|
||
};
|
||
---
|
||
<AppLayout title="Panel — LabPréstamos">
|
||
<div class="max-w-6xl">
|
||
<header class="mb-6">
|
||
<h1 class="text-2xl md:text-3xl font-semibold">Panel</h1>
|
||
<p class="text-sm mt-1" style="color: var(--color-pencil);">Resumen del laboratorio.</p>
|
||
</header>
|
||
|
||
<section aria-label="Indicadores" class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-8">
|
||
{kpis.map((k) => (
|
||
<a href={k.href} class="card-link">
|
||
<div
|
||
class="text-4xl font-semibold leading-none"
|
||
style={`font-variant-numeric: tabular-nums; color: var(--color-${k.tone === 'ink' ? 'ink' : k.tone});`}
|
||
>
|
||
{k.value}
|
||
</div>
|
||
<div class="mt-2 text-sm uppercase tracking-wide" style="color: var(--color-pencil);">{k.label}</div>
|
||
</a>
|
||
))}
|
||
</section>
|
||
|
||
<section aria-labelledby="ultimas-h">
|
||
<div class="flex items-baseline justify-between mb-3">
|
||
<h2 id="ultimas-h" class="text-lg font-semibold uppercase tracking-wide">Últimas 5 solicitudes</h2>
|
||
<a href="/admin/solicitudes" class="text-sm underline decoration-[color:var(--color-ink)] underline-offset-4">Ver todas</a>
|
||
</div>
|
||
<div class="card-flat p-0 overflow-hidden">
|
||
{ultimas.data && ultimas.data.length > 0 ? (
|
||
<table class="w-full text-sm">
|
||
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
|
||
<tr>
|
||
<th class="p-3 font-medium">Alumno</th>
|
||
<th class="p-3 font-medium">Material</th>
|
||
<th class="p-3 font-medium">Estado</th>
|
||
<th class="p-3 font-medium">Solicitado</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ultimas.data.map((r: any) => (
|
||
<tr class="border-t" style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent);">
|
||
<td class="p-3">{r.alumno?.nombre ?? r.alumno?.email ?? '—'}</td>
|
||
<td class="p-3">
|
||
<div class="flex flex-col gap-0.5">
|
||
{(r.items ?? []).map((i: any) => (
|
||
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td class="p-3">{estadoLabel[r.estado] ?? r.estado}</td>
|
||
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<p class="p-6 opacity-70 text-sm">Aún no hay solicitudes registradas.</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</AppLayout>
|