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
@@ -1,9 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast';
type Unidad = { id: number; etiqueta: string };
type Item = {
id: number;
cantidad: number;
descripcion: string | null;
material: { nombre: string; numero_inventario: string | null } | null;
material: { id: number; nombre: string; numero_inventario: string | null; trackeado_por_unidad?: boolean } | null;
material_unidad: Unidad | null;
};
type Detalles = {
prestamo: {
@@ -23,6 +27,117 @@ type Detalles = {
const fmt = new Intl.DateTimeFormat('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
const ERROR_TXT: Record<string, string> = {
no_autorizado: 'No autorizado',
item_no_existe: 'El renglón ya no existe',
solicitud_no_activa: 'La solicitud no está activa',
item_sin_unidad: 'Este renglón no tiene unidad asignada',
unidad_no_existe: 'La unidad seleccionada no existe',
unidad_de_otro_material: 'La unidad no corresponde a este material',
unidad_no_disponible: 'La unidad ya no está disponible',
};
function ReasignarUnidad({
solicitudId,
item,
}: {
solicitudId: number;
item: Item;
}) {
const [open, setOpen] = useState(false);
const [unidades, setUnidades] = useState<Unidad[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<string>('');
const [saving, setSaving] = useState(false);
const abrir = async () => {
setOpen(true);
if (unidades.length || !item.material) return;
setLoading(true);
try {
const r = await fetch(`/api/admin/materiales/${item.material.id}/unidades?estado=disponible`);
const j = await r.json();
if (!r.ok) throw new Error(j?.error ?? 'Error');
setUnidades(j.unidades ?? []);
} catch (e) {
toast({ kind: 'error', title: 'No se pudieron cargar unidades', description: e instanceof Error ? e.message : '' });
} finally {
setLoading(false);
}
};
const confirmar = async () => {
if (!selected || saving) return;
setSaving(true);
try {
const r = await fetch(`/api/admin/solicitudes/${solicitudId}/reasignar-unidad`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: item.id, nueva_unidad_id: Number(selected) }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const key = String(j?.error ?? '');
toast({ kind: 'error', title: ERROR_TXT[key] ?? 'No se pudo reasignar' });
setSaving(false);
return;
}
toastAfterReload({ kind: 'success', title: 'Unidad reasignada' });
location.reload();
} catch {
toast({ kind: 'error', title: 'Error inesperado' });
setSaving(false);
}
};
if (!open) {
return (
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
onClick={abrir}
>
Cambiar unidad
</button>
);
}
return (
<div className="flex flex-wrap items-center gap-1 mt-1">
<select
className="input"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={loading || saving}
>
<option value="">{loading ? 'Cargando…' : 'Selecciona unidad…'}</option>
{unidades.map((u) => (
<option key={u.id} value={String(u.id)}>{u.etiqueta}</option>
))}
</select>
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
onClick={confirmar}
disabled={!selected || saving}
>
{saving ? '…' : 'Confirmar'}
</button>
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
onClick={() => setOpen(false)}
disabled={saving}
>
Cancelar
</button>
</div>
);
}
export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
const ref = useRef<HTMLDialogElement>(null);
const [data, setData] = useState<Detalles | null>(null);
@@ -53,6 +168,8 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
}
};
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
return (
<>
<button type="button" className="btn btn-ghost" onClick={open}>Detalles</button>
@@ -83,12 +200,21 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
<dd className="col-span-2">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt>
<dd className="col-span-2">
<ul className="list-disc pl-4">
{data.prestamo.items.map((i, idx) => (
<li key={idx}>
<ul className="list-disc pl-4 space-y-2">
{data.prestamo.items.map((i) => (
<li key={i.id}>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}</strong>× {i.material?.nombre ?? '—'}{' '}
<span className="opacity-70">· Inv. {i.material?.numero_inventario ?? '—'}</span>
{i.material_unidad?.etiqueta && (
<div className="text-xs">
<span className="opacity-70">Unidad: </span>
<span className="font-semibold">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs opacity-70">{i.descripcion}</div>}
{canReasignar && i.material_unidad && (
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
)}
</li>
))}
</ul>