Files
labre-web/src/pages/alumno/mis-prestamos.astro
T
LakG 81a5ea016e 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>
2026-08-17 08:37:44 -07:00

206 lines
8.7 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import AppLayout from '@/layouts/AppLayout.astro';
type Estado = 'pendiente' | 'aprobado' | 'rechazado' | 'activo' | 'devuelto' | 'vencido';
type Item = {
cantidad: number;
descripcion: string | null;
material: { id: number; nombre: string; numero_inventario: string | null } | null;
};
type Solicitud = {
id: number;
estado: Estado;
fecha_solicitud: string;
fecha_aprobacion: string | null;
fecha_devolucion_estimada: string | null;
fecha_devolucion_real: string | null;
notas: string | null;
maestro_responsable: string;
items: Item[];
};
const user = Astro.locals.user!;
const { data, error } = await Astro.locals.supabase
.from('solicitudes')
.select('id, estado, fecha_solicitud, fecha_aprobacion, fecha_devolucion_estimada, fecha_devolucion_real, notas, maestro_responsable, items:solicitud_items(cantidad, descripcion, material:materiales(id, nombre, numero_inventario))')
.eq('alumno_id', user.id)
.order('fecha_solicitud', { ascending: false });
const prestamos = (data ?? []) as unknown as Solicitud[];
const activosSet = new Set<Estado>(['pendiente', 'aprobado', 'activo']);
const activos = prestamos.filter((p) => activosSet.has(p.estado));
const historial = prestamos.filter((p) => !activosSet.has(p.estado));
const fmtFecha = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium' });
const fmt = (d: string | null) => (d ? fmtFecha.format(new Date(d)) : '—');
const estadoLabel: Record<Estado, string> = {
pendiente: 'Pendiente',
aprobado: 'Aprobado',
activo: 'Activo',
rechazado: 'Rechazado',
devuelto: 'Devuelto',
vencido: 'Vencido',
};
const estadoColor: Record<Estado, string> = {
pendiente: 'var(--color-ink)',
aprobado: 'var(--color-positive)',
activo: 'var(--color-positive)',
devuelto: 'var(--color-ink)',
rechazado: 'var(--color-danger)',
vencido: 'var(--color-danger)',
};
const badgeStyle = (e: Estado) =>
`background: color-mix(in oklab, ${estadoColor[e]} 18%, white); color: var(--color-ink); border: 1.5px solid ${estadoColor[e]};`;
---
<AppLayout title="Mis préstamos — LabPréstamos">
<div class="max-w-4xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Mis préstamos</h1>
<p class="text-sm opacity-70 mt-1">Estado de tus solicitudes actuales e historial.</p>
</header>
{error && (
<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-text);">
No se pudieron cargar tus préstamos. Recarga la página o intenta más tarde.
</p>
</div>
)}
{!error && prestamos.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Aún no has solicitado material</h2>
<p class="text-sm opacity-70 mb-4">Explora el catálogo y envía tu primera solicitud.</p>
<a href="/alumno/catalogo" class="btn btn-primary">Ir al catálogo</a>
</div>
)}
{prestamos.length > 0 && (
<div class="space-y-8">
<section>
<h2 class="text-lg font-semibold mb-3">Activos <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({activos.length})</span></h2>
{activos.length === 0 ? (
<p class="text-sm opacity-70">Nada activo por ahora.</p>
) : (
<ul class="space-y-3">
{activos.map((p) => (
<li class="card">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_aprobacion && (
<>
<dt class="opacity-60">Aprobado</dt>
<dd class="text-right">{fmt(p.fecha_aprobacion)}</dd>
</>
)}
{p.fecha_devolucion_estimada && (
<>
<dt class="opacity-60">Devolver antes de</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_estimada)}</dd>
</>
)}
</dl>
{p.notas && (
<p class="mt-3 text-sm opacity-75 border-l-2 pl-3" style="border-color: color-mix(in oklab, var(--color-ink) 15%, transparent);">
{p.notas}
</p>
)}
</li>
))}
</ul>
)}
</section>
<section>
<details open={historial.length <= 5}>
<summary class="text-lg font-semibold cursor-pointer list-none flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
Historial <span class="text-sm opacity-60" style="font-variant-numeric: tabular-nums;">({historial.length})</span>
</summary>
{historial.length === 0 ? (
<p class="text-sm opacity-70 mt-3">Sin historial todavía.</p>
) : (
<ul class="space-y-3 mt-3">
{historial.map((p) => (
<li class="card" style="opacity: 0.92;">
<div class="flex items-start justify-between gap-3 flex-wrap">
<ul class="min-w-0 space-y-1">
{p.items.map((it) => (
<li class="leading-snug">
<span class="font-semibold" style="font-variant-numeric: tabular-nums;">{it.cantidad}×</span>{' '}
<span class="font-semibold">{it.material?.nombre ?? 'Material eliminado'}</span>
{it.material?.numero_inventario && (
<span class="text-xs opacity-60 font-mono ml-1.5">{it.material.numero_inventario}</span>
)}
{it.descripcion && (
<p class="text-xs opacity-60 ml-0.5">{it.descripcion}</p>
)}
</li>
))}
</ul>
<span class="text-xs font-medium px-2 py-1 rounded-[2px] whitespace-nowrap" style={badgeStyle(p.estado)}>
{estadoLabel[p.estado]}
</span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-y-1 text-sm">
<dt class="opacity-60">Maestro responsable</dt>
<dd class="text-right truncate">{p.maestro_responsable}</dd>
<dt class="opacity-60">Solicitado</dt>
<dd class="text-right">{fmt(p.fecha_solicitud)}</dd>
{p.fecha_devolucion_real && (
<>
<dt class="opacity-60">Devuelto</dt>
<dd class="text-right">{fmt(p.fecha_devolucion_real)}</dd>
</>
)}
</dl>
</li>
))}
</ul>
)}
</details>
</section>
</div>
)}
</div>
</AppLayout>
<style>
details > summary::-webkit-details-marker { display: none; }
details[open] > summary > svg { transform: rotate(90deg); }
details > summary > svg {
transition: transform 150ms ease-out;
}
</style>