Files
labre-web/src/components/alumno/FiltroCategorias.tsx
T
LakG ac3e7061ac Rediseño visual UABC + Mulish + auditoría de BD (P0/P1)
Rediseño visual:
- Nueva identidad UABC/editorial (DESIGN.md), tipografía Mulish, tokens
  verde/dorado/negro sobre bone-white, patrón de puntos verde en canvas,
  radios 8/12/30/9999, sin sombras, hairline 1px, tarjetas con leve
  transparencia + blur suave + borde verde muy sutil.
- 45 archivos .astro/.tsx alineados al nuevo sistema (Track A/B/C/D/E).
- Limpieza post-critique: eliminado triple CTA en home alumno, .card
  anidados en admin, aria-live decorativo, 26 aliases legacy en global.css,
  border-l verde en nav (patrón AI-slop), classList.remove obsoletos.

Auditoría de llamadas a BD (para 20-50 usuarios concurrentes):
- P0-1: middleware con cache in-memory de profile (30s) + is_banned (30s)
  por uid; exclusión de assets estáticos (/_astro/*, /audio/*, favicon).
  Invalidación en signout, PATCH /profile, baneos.
- P0-2: eliminado count pendientes duplicado en AppLayout; badge se
  hidrata client-side (BadgeSolicitudes con fetch inicial).
- P0-3: migración 0008 REPLICA IDENTITY FULL en solicitudes + delta
  preciso en BadgeSolicitudes (con fallback a refetch).
- P1: admin/index dedupe (4 queries → 2 con recientes.filter), catalogo
  reusa profile del middleware (3 queries → 1), inventario paralelo,
  estadisticas con cache 60s en RAM (stats-cache.ts), migración 0009
  con 3 índices (fecha, vencidos parcial, audit compuesto).

Fixes P0 de UI post-critique:
- estadisticas.astro: ReferenceError DIAS restaurado.
- solicitudes (3 vistas): error rendering sanitizado (no expone mensaje
  Postgres crudo al admin).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
2026-09-21 09:42:12 -07:00

94 lines
2.9 KiB
TypeScript

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;
if (show) delete el.dataset.hiddenByCat;
else el.dataset.hiddenByCat = 'true';
});
// El buscador (BuscadorCatalogo, client:load) define este helper y unifica
// ambos flags (cat + search). Fallback si aún no montó: aplicar directo.
if (typeof window !== 'undefined' && window.__labreFiltrar) {
window.__labreFiltrar();
} else {
let anyVisible = false;
cards.forEach((el) => {
el.hidden = el.dataset.hiddenByCat === 'true';
if (!el.hidden) anyVisible = true;
});
const empty = document.getElementById('catalogo-empty-filter');
if (empty) 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
? 'bg-verde-institucional text-bone-white rounded-[8px] px-3 py-1 border border-verde-institucional inline-flex items-center whitespace-nowrap text-xs font-medium cursor-pointer transition-colors'
: 'chip cursor-pointer hover:bg-linen hover:border-dorado-uabc transition-colors';
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)}
data-cat-value={ALL}
data-cat-active={active === ALL ? 'true' : undefined}
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)}
data-cat-value={v}
data-cat-active={active === v ? 'true' : undefined}
onClick={() => pick(v)}
>
{c.nombre}
</button>
);
})}
</div>
);
}