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
This commit is contained in:
2026-08-22 18:03:38 -07:00
parent 43959056f8
commit 544bbf38a9
23 changed files with 1876 additions and 247 deletions
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
// ponytail: coordinar via window para evitar Context entre 2 islands hermanas
declare global {
interface Window {
__labreFiltrar?: () => void;
}
}
const norm = (s: string) =>
s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
function unify() {
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
let anyVisible = false;
cards.forEach((el) => {
const hidden = el.dataset.hiddenByCat === 'true' || el.dataset.hiddenBySearch === 'true';
el.hidden = hidden;
if (!hidden) anyVisible = true;
});
const empty = document.getElementById('catalogo-empty-filter');
if (empty) empty.hidden = anyVisible || cards.length === 0;
}
function applyText(value: string) {
const needle = norm(value.trim());
const cards = document.querySelectorAll<HTMLElement>('[data-cat]');
cards.forEach((el) => {
if (!needle) {
delete el.dataset.hiddenBySearch;
return;
}
const nombre = norm(el.dataset.nombre ?? '');
const inv = norm(el.dataset.numeroInventario ?? '');
const match = nombre.includes(needle) || (!!inv && inv.includes(needle));
if (match) delete el.dataset.hiddenBySearch;
else el.dataset.hiddenBySearch = 'true';
});
unify();
}
export default function BuscadorCatalogo({ q: qInitial = '' }: { q?: string }) {
const [q, setQ] = useState(qInitial);
const timerRef = useRef<number | null>(null);
useEffect(() => {
window.__labreFiltrar = unify;
applyText(qInitial);
return () => {
window.__labreFiltrar = undefined;
if (timerRef.current) window.clearTimeout(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const syncUrl = (value: string) => {
const url = new URL(location.href);
const clean = value.trim();
if (clean) url.searchParams.set('q', clean);
else url.searchParams.delete('q');
history.replaceState(null, '', url.toString());
};
const onChange = (value: string) => {
setQ(value);
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
applyText(value);
syncUrl(value);
}, 120);
};
const clear = () => {
setQ('');
if (timerRef.current) window.clearTimeout(timerRef.current);
applyText('');
syncUrl('');
};
return (
<div>
<label className="label sr-only" htmlFor="buscador-catalogo">
Buscar material
</label>
<div className="relative">
<input
id="buscador-catalogo"
className="input"
type="search"
placeholder="Buscar por nombre o número de inventario…"
value={q}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
style={{ paddingRight: q ? '2.5rem' : undefined }}
/>
{q && (
<button
type="button"
onClick={clear}
aria-label="Limpiar búsqueda"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-[2px] hover:bg-black/5 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
);
}