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,201 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from '@/lib/toast';
|
||||
|
||||
type Unidad = {
|
||||
id: number;
|
||||
etiqueta: string;
|
||||
estado: 'disponible' | 'prestado' | 'mantenimiento' | 'baja';
|
||||
notas: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const ESTADOS = ['disponible', 'mantenimiento', 'baja'] as const;
|
||||
|
||||
export default function UnidadesManager({ materialId }: { materialId: number }) {
|
||||
const [unidades, setUnidades] = useState<Unidad[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [nuevaEtiqueta, setNuevaEtiqueta] = useState('');
|
||||
const [creando, setCreando] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`);
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j?.error ?? 'Error al cargar');
|
||||
setUnidades(j.unidades ?? []);
|
||||
} catch (e) {
|
||||
toast({ kind: 'error', title: 'No se pudieron cargar las unidades', description: e instanceof Error ? e.message : '' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [materialId]);
|
||||
|
||||
const crear = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const et = nuevaEtiqueta.trim();
|
||||
if (!et || creando) return;
|
||||
setCreando(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/materiales/${materialId}/unidades`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ etiqueta: et }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (!res.ok) throw new Error(j?.error ?? 'No se pudo crear');
|
||||
setNuevaEtiqueta('');
|
||||
await load();
|
||||
toast({ kind: 'success', title: 'Unidad agregada', description: et });
|
||||
} catch (e) {
|
||||
toast({ kind: 'error', title: 'No se pudo agregar', description: e instanceof Error ? e.message : '' });
|
||||
} finally {
|
||||
setCreando(false);
|
||||
}
|
||||
};
|
||||
|
||||
const patch = async (u: Unidad, body: Partial<Pick<Unidad, 'etiqueta' | 'estado' | 'notas'>>) => {
|
||||
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ kind: 'error', title: 'No se pudo actualizar', description: j?.error });
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
setUnidades((prev) => prev.map((x) => (x.id === u.id ? { ...x, ...body } as Unidad : x)));
|
||||
};
|
||||
|
||||
const eliminar = async (u: Unidad) => {
|
||||
if (!confirm(`Eliminar unidad "${u.etiqueta}"?`)) return;
|
||||
const res = await fetch(`/api/admin/materiales/unidades/${u.id}`, { method: 'DELETE' });
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
toast({ kind: 'error', title: 'No se pudo eliminar', description: j?.error });
|
||||
return;
|
||||
}
|
||||
setUnidades((prev) => prev.filter((x) => x.id !== u.id));
|
||||
toast({ kind: 'success', title: 'Unidad eliminada' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider mb-3">Unidades individuales</h3>
|
||||
|
||||
<form onSubmit={crear} className="flex gap-2 mb-4">
|
||||
<input
|
||||
className="input flex-1"
|
||||
type="text"
|
||||
placeholder="Etiqueta (ej. Laptop-01)"
|
||||
value={nuevaEtiqueta}
|
||||
onChange={(e) => setNuevaEtiqueta(e.target.value)}
|
||||
maxLength={80}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={creando || !nuevaEtiqueta.trim()}
|
||||
style={{ minHeight: '44px' }}
|
||||
>
|
||||
+ Agregar
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm opacity-70">Cargando…</p>
|
||||
) : unidades.length === 0 ? (
|
||||
<p className="text-sm opacity-70">Sin unidades registradas.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead
|
||||
className="text-left"
|
||||
style={{ background: 'color-mix(in oklab, var(--color-ink) 4%, transparent)' }}
|
||||
>
|
||||
<tr>
|
||||
<th className="px-2 py-2 font-medium">Etiqueta</th>
|
||||
<th className="px-2 py-2 font-medium">Estado</th>
|
||||
<th className="px-2 py-2 font-medium">Notas</th>
|
||||
<th className="px-2 py-2 font-medium text-right">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unidades.map((u) => {
|
||||
const bloqueada = u.estado === 'prestado';
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
className="border-t"
|
||||
style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 12%, transparent)' }}
|
||||
>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.etiqueta}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== u.etiqueta) patch(u, { etiqueta: v });
|
||||
else e.target.value = u.etiqueta;
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<select
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
value={bloqueada ? 'prestado' : u.estado}
|
||||
disabled={bloqueada}
|
||||
onChange={(e) => patch(u, { estado: e.target.value as Unidad['estado'] })}
|
||||
>
|
||||
{bloqueada && <option value="prestado">Prestado</option>}
|
||||
{ESTADOS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
defaultValue={u.notas ?? ''}
|
||||
placeholder="Opcional…"
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value;
|
||||
if ((u.notas ?? '') !== v) patch(u, { notas: v || null });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
|
||||
disabled={bloqueada}
|
||||
onClick={() => eliminar(u)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user