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