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