Files
labre-web/src/components/admin/solicitudes/VerDetalles.tsx
T
LakG ac3e7061ac Rediseño visual UABC + Mulish + auditoría de BD (P0/P1)
Rediseño visual:
- Nueva identidad UABC/editorial (DESIGN.md), tipografía Mulish, tokens
  verde/dorado/negro sobre bone-white, patrón de puntos verde en canvas,
  radios 8/12/30/9999, sin sombras, hairline 1px, tarjetas con leve
  transparencia + blur suave + borde verde muy sutil.
- 45 archivos .astro/.tsx alineados al nuevo sistema (Track A/B/C/D/E).
- Limpieza post-critique: eliminado triple CTA en home alumno, .card
  anidados en admin, aria-live decorativo, 26 aliases legacy en global.css,
  border-l verde en nav (patrón AI-slop), classList.remove obsoletos.

Auditoría de llamadas a BD (para 20-50 usuarios concurrentes):
- P0-1: middleware con cache in-memory de profile (30s) + is_banned (30s)
  por uid; exclusión de assets estáticos (/_astro/*, /audio/*, favicon).
  Invalidación en signout, PATCH /profile, baneos.
- P0-2: eliminado count pendientes duplicado en AppLayout; badge se
  hidrata client-side (BadgeSolicitudes con fetch inicial).
- P0-3: migración 0008 REPLICA IDENTITY FULL en solicitudes + delta
  preciso en BadgeSolicitudes (con fallback a refetch).
- P1: admin/index dedupe (4 queries → 2 con recientes.filter), catalogo
  reusa profile del middleware (3 queries → 1), inventario paralelo,
  estadisticas con cache 60s en RAM (stats-cache.ts), migración 0009
  con 3 índices (fecha, vencidos parcial, audit compuesto).

Fixes P0 de UI post-critique:
- estadisticas.astro: ReferenceError DIAS restaurado.
- solicitudes (3 vistas): error rendering sanitizado (no expone mensaje
  Postgres crudo al admin).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xaxs8cGcQe9NwbMqA1gvn
2026-09-21 09:42:12 -07:00

285 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: { id: number; nombre: string; numero_inventario: string | null; trackeado_por_unidad?: boolean } | null;
material_unidad: Unidad | null;
};
type Detalles = {
prestamo: {
id: number;
estado: string;
fecha_solicitud: string;
fecha_aprobacion: string | null;
fecha_devolucion_estimada: string | null;
fecha_devolucion_real: string | null;
notas: string | null;
maestro_responsable: string;
alumno: { nombre: string | null; email: string; matricula: string | null } | null;
items: Item[];
};
audit_log: Array<Record<string, any>>;
};
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 text-xs py-1 px-2.5 min-h-[28px]"
onClick={abrir}
>
Cambiar unidad
</button>
);
}
return (
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<select
className="input text-xs py-1 px-2 min-h-[32px] w-auto"
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 text-xs py-1 px-2.5 min-h-[32px]"
onClick={confirmar}
disabled={!selected || saving}
>
{saving ? '…' : 'Confirmar'}
</button>
<button
type="button"
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[32px]"
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);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const d = ref.current;
if (!d) return;
const onClick = (e: MouseEvent) => { if (e.target === d) d.close(); };
d.addEventListener('click', onClick);
return () => d.removeEventListener('click', onClick);
}, []);
const open = async () => {
ref.current?.showModal();
if (data) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/admin/solicitudes/${prestamoId}/detalles`);
if (!res.ok) throw new Error(`Error ${res.status}`);
setData(await res.json());
} catch (err: any) {
setError(err.message ?? 'No se pudo cargar el detalle.');
} finally {
setLoading(false);
}
};
const canReasignar = data && ['aprobado', 'activo'].includes(data.prestamo.estado);
const getChipCls = (st: string) => {
const map: Record<string, string> = {
pendiente: 'chip-pendiente',
aprobado: 'chip-aprobado',
activo: 'chip-activo',
devuelto: 'chip-devuelto',
rechazado: 'chip-rechazado',
vencido: 'chip-vencido',
};
return map[st] ?? 'chip-pendiente';
};
return (
<>
<button type="button" className="btn btn-ghost" onClick={open}>Ver detalles</button>
<dialog ref={ref} className="card rounded-[12px] max-w-lg w-[calc(100%-2rem)] backdrop:bg-black/40 shadow-none">
<div className="flex items-start justify-between gap-4">
<h2 className="text-xl md:text-2xl font-light text-graphite">Detalle del préstamo</h2>
<button
type="button"
className="rounded-full p-2 text-graphite/70 hover:text-graphite hover:bg-linen transition-colors"
onClick={() => ref.current?.close()}
aria-label="Cerrar"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{loading && <p className="mt-4 text-sm text-graphite/70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-[12px] p-3 text-sm bg-negro-institucional text-bone-white">
{error}
</div>
)}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-3 text-sm">
<dt className="text-graphite/70">Alumno</dt>
<dd className="col-span-2 text-graphite font-medium">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="text-graphite/70">Maestro</dt>
<dd className="col-span-2 break-words text-graphite">{data.prestamo.maestro_responsable}</dd>
<dt className="text-graphite/70">Materiales</dt>
<dd className="col-span-2">
<ul className="border-y border-hairline-gray my-2 divide-y divide-hairline-gray">
{data.prestamo.items.map((i) => (
<li key={i.id} className="py-2.5 first:pt-2 last:pb-2 border-b border-hairline-gray last:border-b-0">
<div className="flex items-baseline justify-between gap-2">
<div>
<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{i.cantidad}×</strong>{' '}
<span className="font-medium text-graphite">{i.material?.nombre ?? '—'}</span>
<span className="text-xs text-graphite/60 ml-2">Inv. {i.material?.numero_inventario ?? '—'}</span>
</div>
{canReasignar && i.material_unidad && (
<ReasignarUnidad solicitudId={data.prestamo.id} item={i} />
)}
</div>
{i.material_unidad?.etiqueta && (
<div className="text-xs text-graphite/70 mt-1">
Unidad: <span className="font-medium text-graphite">{i.material_unidad.etiqueta}</span>
</div>
)}
{i.descripcion && <div className="text-xs text-graphite/60 mt-0.5">{i.descripcion}</div>}
</li>
))}
</ul>
</dd>
<dt className="text-graphite/70">Solicitado</dt>
<dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="text-graphite/70">Aprobado</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="text-graphite/70">Devolver antes</dt><dd className="col-span-2 text-graphite">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="text-graphite/70">Notas</dt><dd className="col-span-2 text-graphite whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-3 text-base font-medium text-graphite">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm text-graphite/70">Sin cambios registrados.</p>
) : (
<div className="relative pl-4 border-l border-hairline-gray space-y-3 my-2">
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
const estadoNuevo = e.estado_nuevo;
const estadoAnt = e.estado_anterior;
return (
<div key={i} className="relative">
<div className="absolute -left-[21px] top-1.5 w-2 h-2 rounded-full bg-verde-institucional" />
<div className="flex items-center justify-between gap-2 flex-wrap text-sm">
<span className="font-medium text-graphite capitalize">{e.accion ?? 'Cambio'}</span>
{fecha && <span className="text-xs text-graphite/60">{fmt.format(new Date(fecha))}</span>}
</div>
{(estadoAnt || estadoNuevo) && (
<div className="flex items-center gap-1.5 mt-1 text-xs">
{estadoAnt && (
<>
<span className={`chip ${getChipCls(estadoAnt)}`}>{estadoAnt}</span>
<span className="text-graphite/40">→</span>
</>
)}
{estadoNuevo && (
<span className={`chip ${getChipCls(estadoNuevo)}`}>{estadoNuevo}</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</>
)}
</dialog>
</>
);
}