Sistema de préstamos LabRe UABC — implementación inicial
Sistema web para gestión de préstamos de material del Laboratorio de Sistemas Computacionales de la UABC. - Backend: Supabase self-hosted, schema aislado `prestamos` con RLS, triggers de stock y audit log (supabase/migrations/0001_init.sql). - Auth: Google OAuth restringido a @uabc.edu.mx, verificado en middleware y como segunda línea en trigger de DB. - Frontend: Astro 7 (SSR con adapter Node) + React islands + Tailwind v4 con paleta UABC (primary #00723F, secondary #DD971A) bajo regla 60/30/10. - Interfaz alumno mobile-first: catálogo con filtro por categorías, solicitud de préstamos, historial personal. - Interfaz admin desktop-first: panel con KPIs, bandeja de solicitudes (aprobar/rechazar/devolver), CRUD de inventario y categorías, reportes filtrables con export CSV nativo. - Modales con `<dialog>` nativo, cero librerías de UI adicionales. - Deploy: Dockerfile multi-stage node:22-alpine + docker-compose para publicar bajo prestamos.buglabs.dev vía Cloudflare Tunnel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
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;
|
||||
alumno: { nombre: string | null; email: string; matricula: string | null } | null;
|
||||
material: { nombre: string; numero_inventario: string | null } | null;
|
||||
};
|
||||
audit_log: Array<Record<string, any>>;
|
||||
};
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
|
||||
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [data, setData] = useState<Detalles | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
if (!d) return;
|
||||
const onClick = (e: MouseEvent) => { if (e.target === d) d.close(); };
|
||||
d.addEventListener('click', onClick);
|
||||
return () => d.removeEventListener('click', onClick);
|
||||
}, []);
|
||||
|
||||
const open = async () => {
|
||||
ref.current?.showModal();
|
||||
if (data) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/prestamos/${prestamoId}/detalles`);
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
setData(await res.json());
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'No se pudo cargar el detalle.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
|
||||
<dialog ref={ref} className="rounded-lg p-0 max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => ref.current?.close()} aria-label="Cerrar">×</button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="mt-4 text-sm opacity-70">Cargando…</p>}
|
||||
{error && (
|
||||
<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)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<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">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></>)}
|
||||
{data.prestamo.fecha_devolucion_estimada && (<><dt className="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
|
||||
{data.prestamo.notas && (<><dt className="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
|
||||
</dl>
|
||||
|
||||
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3>
|
||||
{data.audit_log.length === 0 ? (
|
||||
<p className="text-sm opacity-70">Sin cambios registrados.</p>
|
||||
) : (
|
||||
<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;
|
||||
return (
|
||||
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
|
||||
<span className="font-medium">{e.accion ?? 'cambio'}</span>
|
||||
<span className="opacity-80">
|
||||
{(e.estado_anterior ?? '—')} → {(e.estado_nuevo ?? '—')}
|
||||
</span>
|
||||
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user