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