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
@@ -1,17 +1,22 @@
import { useEffect, useRef, useState } from 'react';
type Item = {
cantidad: number;
descripcion: string | null;
material: { nombre: string; numero_inventario: string | null } | null;
};
type Detalles = {
prestamo: {
id: number;
cantidad: number;
estado: string;
fecha_solicitud: string;
fecha_aprobacion: string | null;
fecha_devolucion_estimada: string | null;
fecha_devolucion_real: string | null;
notas: string | null;
maestro_responsable: string;
alumno: { nombre: string | null; email: string; matricula: string | null } | null;
material: { nombre: string; numero_inventario: string | null } | null;
items: Item[];
};
audit_log: Array<Record<string, any>>;
};
@@ -38,7 +43,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/prestamos/${prestamoId}/detalles`);
const res = await fetch(`/api/admin/solicitudes/${prestamoId}/detalles`);
if (!res.ok) throw new Error(`Error ${res.status}`);
setData(await res.json());
} catch (err: any) {
@@ -74,10 +79,20 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Material</dt>
<dd className="col-span-2">{data.prestamo.material?.nombre} <span className="opacity-70">· Inv. {data.prestamo.material?.numero_inventario ?? '—'}</span></dd>
<dt className="opacity-70">Cantidad</dt>
<dd className="col-span-2" style={{ fontVariantNumeric: 'tabular-nums' }}>{data.prestamo.cantidad}</dd>
<dt className="opacity-70">Maestro</dt>
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt>
<dd className="col-span-2">
<ul className="list-disc pl-4">
{data.prestamo.items.map((i, idx) => (
<li key={idx}>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
</li>
))}
</ul>
</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
@@ -91,7 +106,7 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{data.audit_log.map((e, i) => {
const fecha = e.created_at ?? e.fecha ?? e.timestamp;
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>