ac3e7061ac
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
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|