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
This commit is contained in:
2026-09-21 09:42:12 -07:00
parent a41d50ca62
commit ac3e7061ac
53 changed files with 2132 additions and 1576 deletions
+109 -79
View File
@@ -94,8 +94,7 @@ function ReasignarUnidad({
return (
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '30px', paddingBlock: '0.15rem', fontSize: '0.75rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[28px]"
onClick={abrir}
>
Cambiar unidad
@@ -103,10 +102,9 @@ function ReasignarUnidad({
);
}
return (
<div className="flex flex-wrap items-center gap-1 mt-1">
<div className="flex flex-wrap items-center gap-1.5 mt-1">
<select
className="input"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="input text-xs py-1 px-2 min-h-[32px] w-auto"
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={loading || saving}
@@ -118,8 +116,7 @@ function ReasignarUnidad({
</select>
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-primary text-xs py-1 px-2.5 min-h-[32px]"
onClick={confirmar}
disabled={!selected || saving}
>
@@ -127,8 +124,7 @@ function ReasignarUnidad({
</button>
<button
type="button"
className="btn btn-ghost"
style={{ minHeight: '32px', paddingBlock: '0.2rem', fontSize: '0.85rem' }}
className="btn btn-ghost text-xs py-1 px-2.5 min-h-[32px]"
onClick={() => setOpen(false)}
disabled={saving}
>
@@ -170,84 +166,118 @@ export default function VerDetalles({ prestamoId }: { prestamoId: number }) {
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}>Detalles</button>
<dialog ref={ref} className="rounded-lg p-0 max-w-lg w-[calc(100%-2rem)] bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40">
<div className="p-6">
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">Detalle del préstamo</h2>
<button type="button" className="rounded-lg p-1.5 hover:bg-black/5 transition-colors" onClick={() => ref.current?.close()} aria-label="Cerrar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<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>
)}
{loading && <p className="mt-4 text-sm opacity-70">Cargando…</p>}
{error && (
<div role="alert" className="mt-3 rounded-lg p-2 text-sm" style={{ background: 'color-mix(in oklab, var(--color-danger) 12%, transparent)', color: 'var(--color-danger-text)' }}>
{error}
</div>
)}
{data && (
<>
<dl className="mt-4 grid grid-cols-3 gap-y-2 text-sm">
<dt className="opacity-70">Alumno</dt>
<dd className="col-span-2">{data.prestamo.alumno?.nombre ?? data.prestamo.alumno?.email}</dd>
<dt className="opacity-70">Maestro</dt>
<dd className="col-span-2 break-words">{data.prestamo.maestro_responsable}</dd>
<dt className="opacity-70">Materiales</dt>
<dd className="col-span-2">
<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>}
{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} />
)}
</li>
))}
</ul>
</dd>
<dt className="opacity-70">Solicitado</dt>
<dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_solicitud))}</dd>
{data.prestamo.fecha_aprobacion && (<><dt className="opacity-70">Aprobado</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_aprobacion))}</dd></>)}
{data.prestamo.fecha_devolucion_estimada && (<><dt className="opacity-70">Devolver antes</dt><dd className="col-span-2">{fmt.format(new Date(data.prestamo.fecha_devolucion_estimada + 'T00:00:00'))}</dd></>)}
{data.prestamo.notas && (<><dt className="opacity-70">Notas</dt><dd className="col-span-2 whitespace-pre-wrap">{data.prestamo.notas}</dd></>)}
</dl>
<h3 className="mt-6 mb-2 font-semibold text-sm">Historial de cambios</h3>
{data.audit_log.length === 0 ? (
<p className="text-sm opacity-70">Sin cambios registrados.</p>
) : (
<ul className="text-sm divide-y" style={{ borderColor: 'color-mix(in oklab, var(--color-ink) 10%, transparent)' }}>
{data.audit_log.map((e, i) => {
const fecha = e.at ?? e.created_at ?? e.fecha ?? e.timestamp;
return (
<li key={i} className="py-2 flex items-baseline gap-2 flex-wrap">
<span className="font-medium">{e.accion ?? 'cambio'}</span>
<span className="opacity-80">
{(e.estado_anterior ?? '—')} → {(e.estado_nuevo ?? '—')}
</span>
{fecha && <span className="opacity-60 ml-auto text-xs">{fmt.format(new Date(fecha))}</span>}
</li>
);
})}
</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>
)}
</>
)}
</div>
</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>
</>
);