Rediseño: vale de préstamo multi-ítem, fiel al formulario de papel

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>
This commit is contained in:
2026-08-17 08:37:44 -07:00
parent 4005008872
commit 81a5ea016e
23 changed files with 999 additions and 413 deletions
+23 -11
View File
@@ -4,9 +4,9 @@ import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.
const supabase = Astro.locals.supabase;
const { data, error } = await supabase
.from('prestamos')
.from('solicitudes')
.select(
'id, cantidad, fecha_solicitud, notas, alumno:profiles!alumno_id(id, nombre, email, matricula), material:materiales!material_id(id, nombre, numero_inventario, cantidad_disponible)'
'id, fecha_solicitud, notas, maestro_responsable, alumno:profiles!alumno_id(id, nombre, email, matricula), items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario, cantidad_disponible))'
)
.eq('estado', 'pendiente')
.order('fecha_solicitud', { ascending: true });
@@ -27,7 +27,7 @@ const tabs = [
<h1 class="text-2xl md:text-3xl font-semibold">Solicitudes pendientes</h1>
<span
class="inline-flex items-center rounded-[2px] px-2.5 py-0.5 text-sm font-semibold"
style="background: var(--color-primary); color: var(--color-ink); border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
style="background: var(--color-primary); color: white; border: 1.5px solid var(--color-ink); font-variant-numeric: tabular-nums;"
aria-label={`${filas.length} pendientes`}
>
{filas.length}
@@ -74,7 +74,6 @@ const tabs = [
<tr>
<th class="p-3 font-medium">Alumno</th>
<th class="p-3 font-medium">Material</th>
<th class="p-3 font-medium">Cantidad</th>
<th class="p-3 font-medium">Solicitado</th>
<th class="p-3 font-medium text-right">Acciones</th>
</tr>
@@ -85,11 +84,18 @@ const tabs = [
<td class="p-3">
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mt-1">Maestro: {r.maestro_responsable}</div>
</td>
<td class="p-3">
<div>{r.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">
Inv. {r.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{r.material?.cantidad_disponible ?? 0}</span>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div>
<div>{i.cantidad}× {i.material?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">
Inv. {i.material?.numero_inventario ?? '—'} · disp. <span style="font-variant-numeric: tabular-nums;">{i.material?.cantidad_disponible ?? 0}</span>
</div>
</div>
))}
</div>
{r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
@@ -97,7 +103,6 @@ const tabs = [
</div>
)}
</td>
<td class="p-3" style="font-variant-numeric: tabular-nums;">{r.cantidad}</td>
<td class="p-3 opacity-80">{fmtFecha.format(new Date(r.fecha_solicitud))}</td>
<td class="p-3">
<div class="flex justify-end">
@@ -118,9 +123,16 @@ const tabs = [
<div class="font-medium">{r.alumno?.nombre ?? '—'}</div>
<div class="text-xs opacity-70">{fmtFecha.format(new Date(r.fecha_solicitud))}</div>
</div>
<div class="text-xs opacity-70 mb-2">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-sm">{r.material?.nombre ?? '—'} <span class="opacity-70">· Inv. {r.material?.numero_inventario ?? '—'}</span></div>
<div class="text-sm mt-1">Cantidad: <strong style="font-variant-numeric: tabular-nums;">{r.cantidad}</strong> <span class="opacity-70">· disp. {r.material?.cantidad_disponible ?? 0}</span></div>
<div class="text-xs opacity-70">{r.alumno?.matricula ?? ''} {r.alumno?.email ? `· ${r.alumno.email}` : ''}</div>
<div class="text-xs opacity-70 mb-2">Maestro: {r.maestro_responsable}</div>
<div class="flex flex-col gap-1.5">
{(r.items ?? []).map((i: any) => (
<div class="text-sm">
<strong style="font-variant-numeric: tabular-nums;">{i.cantidad}×</strong> {i.material?.nombre ?? '—'}
<span class="opacity-70"> · Inv. {i.material?.numero_inventario ?? '—'} · disp. {i.material?.cantidad_disponible ?? 0}</span>
</div>
))}
</div>
{r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> {r.notas}