Files
labre-web/src/components/alumno/FiltroCategorias.tsx
T
LakG 544bbf38a9 5 features: buscador catálogo, grid+fotos+unidades, estadísticas separada, badge realtime
Buscador catálogo alumno:
- BuscadorCatalogo.tsx con debounce 120ms, normaliza tildes, ?q= en URL
- FiltroCategorias usa data-* attributes, coordinación via window.__labreFiltrar
- Cards con data-nombre y data-numero-inventario para filtro combinable AND

Grid + imágenes + unidades individuales (admin):
- Inventario ahora es grid 2/3/4/5 col con fotos aspect-square
- MaterialForm: input file con preview, checkbox trackeado_por_unidad
- UnidadesManager: agregar/editar/eliminar unidades por material
- Reasignar unidad desde detalles de solicitud (aprobada/activa)
- Alumno ve etiqueta de unidad asignada en mis-préstamos
- Helper materialImg.imgUrl(path) para URL pública del bucket
- Auto-submit debounced del filtro de inventario (input + selects)

Estadísticas separada:
- Nueva ruta /admin/estadisticas con 6 KPIs (movidos de /admin/inventario)
- 3 gráficas recharts: top 10 materiales, solicitudes/día, distribución estados
- Chart.tsx wrapper con paleta UABC + respeta prefers-reduced-motion
- Nav admin: Panel · Solicitudes · Inventario (box) · Estadísticas (stats) · Reportes
- admin/index.astro usa todayMX() (fix inconsistencia)

Notificaciones pendientes (admin):
- Badge SSR con conteo de pendientes sobre ícono Solicitudes (sidebar + dock)
- BadgeSolicitudes.tsx: canal Realtime en prestamos.solicitudes, +1 en INSERT
  con toast, refetch en UPDATE (replica identity default no trae old.estado)
- Recharts añadido a package.json
2026-08-22 18:03:38 -07:00

101 lines
3.0 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
? '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)}
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)}
style={chipStyle(v)}
data-cat-value={v}
data-cat-active={active === v ? 'true' : undefined}
onClick={() => pick(v)}
>
{c.nombre}
</button>
);
})}
</div>
);
}