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,87 @@
import { useEffect, useState } from 'react';
type Cat = { id: number; nombre: string };
type Props = { categorias: Cat[] };
const ALL = 'all';
function applyFilter(value: string) {
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
cards.forEach((el) => {
const cat = el.dataset.cat;
const show = value === ALL || cat === value;
el.hidden = !show;
});
const empty = document.getElementById('catalogo-empty-filter');
if (empty) {
const anyVisible = Array.from(cards).some((el) => !el.hidden);
empty.hidden = anyVisible || cards.length === 0;
}
}
export default function FiltroCategorias({ categorias }: Props) {
const [active, setActive] = useState<string>(ALL);
useEffect(() => {
const url = new URL(location.href);
const cat = url.searchParams.get('cat');
const initial = cat && categorias.some((c) => String(c.id) === cat) ? cat : ALL;
setActive(initial);
applyFilter(initial);
}, [categorias]);
const pick = (value: string) => {
setActive(value);
const url = new URL(location.href);
if (value === ALL) url.searchParams.delete('cat');
else url.searchParams.set('cat', value);
history.replaceState(null, '', url.toString());
applyFilter(value);
};
const chipClass = (v: string) =>
v === active
? 'btn'
: 'btn btn-ghost';
const chipStyle = (v: string): React.CSSProperties =>
v === active
? { background: 'var(--color-primary)', color: 'white', minHeight: '40px', paddingBlock: '0.375rem' }
: { minHeight: '40px', paddingBlock: '0.375rem' };
return (
<div
role="tablist"
aria-label="Filtrar por categoría"
className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-1"
style={{ scrollbarWidth: 'thin' }}
>
<button
type="button"
role="tab"
aria-selected={active === ALL}
className={chipClass(ALL)}
style={chipStyle(ALL)}
onClick={() => pick(ALL)}
>
Todas
</button>
{categorias.map((c) => {
const v = String(c.id);
return (
<button
key={c.id}
type="button"
role="tab"
aria-selected={active === v}
className={chipClass(v)}
style={chipStyle(v)}
onClick={() => pick(v)}
>
{c.nombre}
</button>
);
})}
</div>
);
}