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:
2026-08-14 19:37:49 -07:00
parent 6bbd6d3a6b
commit 1121ab7199
58 changed files with 6281 additions and 252 deletions
@@ -0,0 +1,288 @@
import { useEffect, useRef, useState } from 'react';
type Categoria = { id: number; nombre: string };
type Material = {
id: number;
nombre: string;
categoria_id: number | null;
descripcion: string | null;
cantidad_total: number;
cantidad_disponible: number;
numero_inventario: string | null;
estado: 'disponible' | 'mantenimiento' | 'baja';
};
type Props = {
mode: 'create' | 'edit';
material?: Material;
categorias: Categoria[];
};
export default function MaterialForm({ mode, material, categorias }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [nombre, setNombre] = useState(material?.nombre ?? '');
const [categoriaId, setCategoriaId] = useState<string>(
material?.categoria_id != null ? String(material.categoria_id) : '',
);
const [descripcion, setDescripcion] = useState(material?.descripcion ?? '');
const [cantidadTotal, setCantidadTotal] = useState<string>(
material ? String(material.cantidad_total) : '0',
);
const [numeroInventario, setNumeroInventario] = useState(material?.numero_inventario ?? '');
const [estado, setEstado] = useState<Material['estado']>(material?.estado ?? 'disponible');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleId = `mat-form-${mode}-${material?.id ?? 'new'}`;
const open = () => {
setError(null);
if (mode === 'create') {
setNombre('');
setCategoriaId('');
setDescripcion('');
setCantidadTotal('0');
setNumeroInventario('');
setEstado('disponible');
}
dialogRef.current?.showModal();
queueMicrotask(() => firstFieldRef.current?.focus());
};
const close = () => {
if (loading) return;
dialogRef.current?.close();
};
useEffect(() => {
const dlg = dialogRef.current;
if (!dlg) return;
const onClick = (e: MouseEvent) => {
if (e.target === dlg) close();
};
dlg.addEventListener('click', onClick);
return () => dlg.removeEventListener('click', onClick);
});
useEffect(() => {
if (error) {
errorRef.current?.focus();
}
}, [error]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
setLoading(true);
setError(null);
const payload = {
nombre: nombre.trim(),
categoria_id: categoriaId === '' ? null : Number(categoriaId),
descripcion: descripcion.trim() || null,
cantidad_total: Number(cantidadTotal),
numero_inventario: numeroInventario.trim() || null,
estado,
};
if (!payload.nombre) {
setError('El nombre es obligatorio');
setLoading(false);
return;
}
if (!Number.isInteger(payload.cantidad_total) || payload.cantidad_total < 0) {
setError('La cantidad total debe ser un entero mayor o igual a 0');
setLoading(false);
return;
}
const url =
mode === 'create' ? '/api/admin/materiales' : `/api/admin/materiales/${material!.id}`;
const method = mode === 'create' ? 'POST' : 'PATCH';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo guardar');
dialogRef.current?.close();
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
} finally {
setLoading(false);
}
};
const triggerLabel = mode === 'create' ? 'Nuevo material' : 'Editar';
const triggerClass = mode === 'create' ? 'btn btn-primary' : 'btn btn-ghost';
return (
<>
<button type="button" className={triggerClass} onClick={open}>
{triggerLabel}
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] backdrop:bg-black/40 w-[min(92vw,32rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
{mode === 'create' ? 'Nuevo material' : `Editar: ${material?.nombre}`}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
>
<span aria-hidden="true">×</span>
</button>
</div>
<div>
<label className="label" htmlFor={`${titleId}-nombre`}>
Nombre
</label>
<input
ref={firstFieldRef}
id={`${titleId}-nombre`}
className="input"
type="text"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${titleId}-err` : undefined}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor={`${titleId}-cat`}>
Categoría
</label>
<select
id={`${titleId}-cat`}
className="input"
value={categoriaId}
onChange={(e) => setCategoriaId(e.target.value)}
>
<option value="">Sin categoría</option>
{categorias.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.nombre}
</option>
))}
</select>
</div>
<div>
<label className="label" htmlFor={`${titleId}-inv`}>
de inventario
</label>
<input
id={`${titleId}-inv`}
className="input"
type="text"
value={numeroInventario}
onChange={(e) => setNumeroInventario(e.target.value)}
autoComplete="off"
placeholder="Opcional…"
/>
</div>
</div>
<div>
<label className="label" htmlFor={`${titleId}-desc`}>
Descripción
</label>
<textarea
id={`${titleId}-desc`}
className="input"
rows={3}
maxLength={500}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
placeholder="Detalles, especificaciones, notas…"
style={{ minHeight: '80px' }}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor={`${titleId}-total`}>
Cantidad total
</label>
<input
id={`${titleId}-total`}
className="input"
type="number"
min={0}
step={1}
inputMode="numeric"
value={cantidadTotal}
onChange={(e) => setCantidadTotal(e.target.value)}
required
style={{ fontVariantNumeric: 'tabular-nums' }}
/>
{mode === 'edit' && material && (
<p className="text-xs opacity-60 mt-1">
Prestados: {material.cantidad_total - material.cantidad_disponible}
</p>
)}
</div>
<div>
<label className="label" htmlFor={`${titleId}-estado`}>
Estado
</label>
<select
id={`${titleId}-estado`}
className="input"
value={estado}
onChange={(e) => setEstado(e.target.value as Material['estado'])}
>
<option value="disponible">Disponible</option>
<option value="mantenimiento">Mantenimiento</option>
<option value="baja">Baja</option>
</select>
</div>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger)' }}
>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Guardando…' : 'Guardar'}
</button>
</div>
</form>
</dialog>
</>
);
}