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,15 +1,17 @@
import { useEffect, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
type Item = { cantidad: number; material: { nombre: string; cantidad_disponible: number } | null };
type Prestamo = {
id: number;
cantidad: number;
material: { nombre: string; cantidad_disponible: number } | null;
items: Item[];
maestro_responsable: string;
alumno: { nombre: string | null; email: string } | null;
};
const hoy = () => new Date().toISOString().split('T')[0];
const enDias = (d: number) => new Date(Date.now() + d * 86400000).toISOString().split('T')[0];
const resumenNombres = (items: Item[]) => items.map((i) => i.material?.nombre ?? 'Material').join(', ');
export default function AccionesSolicitud({ prestamo }: { prestamo: Prestamo }) {
return (
@@ -43,7 +45,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
const [notas, setNotas] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const stockInsuficiente = (prestamo.material?.cantidad_disponible ?? 0) < prestamo.cantidad;
const stockInsuficiente = prestamo.items.some((i) => (i.material?.cantidad_disponible ?? 0) < i.cantidad);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -54,7 +56,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/aprobar`, {
const res = await fetch(`/api/admin/solicitudes/${prestamo.id}/aprobar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ fecha_devolucion_estimada: fecha, notas: notas.trim() || undefined }),
@@ -66,7 +68,7 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
toastAfterReload({
kind: 'success',
title: 'Solicitud aprobada',
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
description: `${resumenNombres(prestamo.items)} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
});
location.reload();
} catch (err: any) {
@@ -84,12 +86,21 @@ function AprobarDialog({ prestamo }: { prestamo: Prestamo }) {
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Aprobar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita{' '}
<strong>{prestamo.cantidad}</strong> de <strong>{prestamo.material?.nombre ?? '—'}</strong>.
{prestamo.alumno?.nombre ?? prestamo.alumno?.email} solicita, para {prestamo.maestro_responsable}:
</p>
<ul className="text-sm mt-1 list-disc pl-5">
{prestamo.items.map((i, idx) => (
<li key={idx}>
<strong>{i.cantidad}</strong> de <strong>{i.material?.nombre ?? '—'}</strong>
{(i.material?.cantidad_disponible ?? 0) < i.cantidad && (
<span style={{ color: 'var(--color-danger-text)' }}> stock insuficiente ({i.material?.cantidad_disponible ?? 0} disp.)</span>
)}
</li>
))}
</ul>
{stockInsuficiente && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
Stock disponible ({prestamo.material?.cantidad_disponible ?? 0}) menor a lo solicitado.
Uno o más materiales no tienen stock suficiente.
</div>
)}
<div className="mt-4">
@@ -146,7 +157,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
}
setLoading(true);
try {
const res = await fetch(`/api/admin/prestamos/${prestamo.id}/rechazar`, {
const res = await fetch(`/api/admin/solicitudes/${prestamo.id}/rechazar`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ motivo: motivo.trim() }),
@@ -158,7 +169,7 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
toastAfterReload({
kind: 'info',
title: 'Solicitud rechazada',
description: `${prestamo.material?.nombre ?? 'Material'} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
description: `${resumenNombres(prestamo.items)} · ${prestamo.alumno?.nombre ?? prestamo.alumno?.email ?? ''}`,
});
location.reload();
} catch (err: any) {
@@ -181,6 +192,9 @@ function RechazarDialog({ prestamo }: { prestamo: Prestamo }) {
<dialog ref={dlg.ref} className="rounded-lg p-0 max-w-md w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<form onSubmit={submit} className="p-6">
<h2 className="text-lg font-semibold">Rechazar solicitud</h2>
<p className="text-sm opacity-70 mt-1">
{resumenNombres(prestamo.items)} · {prestamo.alumno?.nombre ?? prestamo.alumno?.email}
</p>
<p className="text-sm opacity-70 mt-1">
Explica al alumno por qué se rechaza. Este texto queda registrado.
</p>