544bbf38a9
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
112 lines
3.3 KiB
TypeScript
112 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|