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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user