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:
@@ -2,161 +2,88 @@
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import Chart from '@/components/admin/estadisticas/Chart.tsx';
|
||||
import { todayMX } from '@/lib/date';
|
||||
import { getEstadisticas } from '@/lib/stats-cache';
|
||||
|
||||
// El middleware ya garantiza rol admin en /admin/*.
|
||||
const supabase = Astro.locals.supabase;
|
||||
const dia = todayMX();
|
||||
const countHead = { count: 'exact' as const, head: true };
|
||||
const startISO = dia.startUTC.toISOString();
|
||||
const endISO = dia.endUTC.toISOString();
|
||||
|
||||
// KPIs del día — misma lógica que /admin/inventario legacy.
|
||||
const [
|
||||
{ count: nSolicitadas },
|
||||
{ count: nAprobadas },
|
||||
{ count: nRechazadas },
|
||||
{ count: nDevueltas },
|
||||
{ count: nEnPrestamo },
|
||||
{ count: nVencidos },
|
||||
] = await Promise.all([
|
||||
supabase.from('solicitudes').select('id', countHead).gte('fecha_solicitud', startISO).lt('fecha_solicitud', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'aprobado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'rechazado').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('audit_log').select('id', countHead).eq('estado_nuevo', 'devuelto').gte('at', startISO).lt('at', endISO),
|
||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']),
|
||||
supabase.from('solicitudes').select('id', countHead).in('estado', ['aprobado', 'activo']).lt('fecha_devolucion_estimada', dia.isoDate),
|
||||
]);
|
||||
// Cache 60s en RAM (getEstadisticas absorbe >90% de visitas repetidas).
|
||||
const { kpis: k, topMateriales, solicitudesPorDia, distribEstados } = await getEstadisticas(supabase, dia);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Solicitadas', value: nSolicitadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Aprobadas', value: nAprobadas ?? 0, tone: 'primary' },
|
||||
{ label: 'Rechazadas', value: nRechazadas ?? 0, tone: 'neutral' },
|
||||
{ label: 'Devoluciones', value: nDevueltas ?? 0, tone: 'primary' },
|
||||
{ label: 'En préstamo', value: nEnPrestamo ?? 0, tone: 'secondary', total: true },
|
||||
{ label: 'Vencidos', value: nVencidos ?? 0, tone: (nVencidos ?? 0) > 0 ? 'danger' : 'neutral', total: true },
|
||||
];
|
||||
|
||||
const toneStyle = (tone: string) => {
|
||||
if (tone === 'primary') return 'color: var(--color-primary);';
|
||||
if (tone === 'secondary') return 'color: var(--color-secondary-hover);';
|
||||
if (tone === 'danger') return 'color: var(--color-danger-text);';
|
||||
return 'color: color-mix(in oklab, var(--color-ink) 65%, transparent);';
|
||||
};
|
||||
|
||||
// Ventana 30 días (rango inclusivo del día actual).
|
||||
// Debe coincidir con la ventana en stats-cache.ts (rango de las 3 gráficas).
|
||||
const DIAS = 30;
|
||||
const desde = new Date(dia.startUTC.getTime() - (DIAS - 1) * 86400000).toISOString();
|
||||
|
||||
// (1) Top 10 materiales solicitados últimos 30 días.
|
||||
const { data: topMaterialesRaw } = await supabase
|
||||
.from('solicitud_items')
|
||||
.select('cantidad, material:materiales(nombre), solicitud:solicitudes!inner(fecha_solicitud)')
|
||||
.gte('solicitud.fecha_solicitud', desde);
|
||||
|
||||
const topMapa = new Map<string, number>();
|
||||
for (const r of (topMaterialesRaw ?? []) as any[]) {
|
||||
const nom = r.material?.nombre ?? '—';
|
||||
topMapa.set(nom, (topMapa.get(nom) ?? 0) + (r.cantidad ?? 0));
|
||||
}
|
||||
const topMateriales = [...topMapa.entries()]
|
||||
.map(([label, valor]) => ({ label, valor }))
|
||||
.sort((a, b) => b.valor - a.valor)
|
||||
.slice(0, 10);
|
||||
|
||||
// (2) Solicitudes por día últimos 30 días. Rellena huecos con 0.
|
||||
const { data: solicitudesUlt30 } = await supabase
|
||||
.from('solicitudes')
|
||||
.select('fecha_solicitud')
|
||||
.gte('fecha_solicitud', desde)
|
||||
.order('fecha_solicitud', { ascending: true });
|
||||
|
||||
// Genera todas las fechas de la ventana en formato YYYY-MM-DD (zona MX).
|
||||
function toIsoDateMX(d: Date): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'America/Tijuana',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
const conteoPorDia = new Map<string, number>();
|
||||
for (const r of (solicitudesUlt30 ?? []) as { fecha_solicitud: string }[]) {
|
||||
const k = toIsoDateMX(new Date(r.fecha_solicitud));
|
||||
conteoPorDia.set(k, (conteoPorDia.get(k) ?? 0) + 1);
|
||||
}
|
||||
const solicitudesPorDia: { fecha: string; valor: number }[] = [];
|
||||
for (let i = 0; i < DIAS; i++) {
|
||||
const d = new Date(dia.startUTC.getTime() - (DIAS - 1 - i) * 86400000);
|
||||
const iso = toIsoDateMX(d);
|
||||
solicitudesPorDia.push({ fecha: iso, valor: conteoPorDia.get(iso) ?? 0 });
|
||||
}
|
||||
|
||||
// (3) Distribución de estados de todas las solicitudes.
|
||||
const { data: estadosRaw } = await supabase.from('solicitudes').select('estado');
|
||||
const estadoLabelMap: Record<string, string> = {
|
||||
pendiente: 'Pendiente',
|
||||
aprobado: 'Aprobado',
|
||||
activo: 'Activo',
|
||||
devuelto: 'Devuelto',
|
||||
rechazado: 'Rechazado',
|
||||
vencido: 'Vencido',
|
||||
};
|
||||
const estadoMapa = new Map<string, number>();
|
||||
for (const r of (estadosRaw ?? []) as { estado: string | null }[]) {
|
||||
if (!r.estado) continue;
|
||||
estadoMapa.set(r.estado, (estadoMapa.get(r.estado) ?? 0) + 1);
|
||||
}
|
||||
const distribEstados = [...estadoMapa.entries()].map(([k, v]) => ({
|
||||
label: estadoLabelMap[k] ?? k,
|
||||
valor: v,
|
||||
}));
|
||||
const vencidosCount = k.nVencidos;
|
||||
const kpis = [
|
||||
{ label: 'Solicitadas', value: k.nSolicitadas, total: false, isVencidos: false },
|
||||
{ label: 'Aprobadas', value: k.nAprobadas, total: false, isVencidos: false },
|
||||
{ label: 'Rechazadas', value: k.nRechazadas, total: false, isVencidos: false },
|
||||
{ label: 'Devoluciones', value: k.nDevueltas, total: false, isVencidos: false },
|
||||
{ label: 'En préstamo', value: k.nEnPrestamo, total: true, isVencidos: false },
|
||||
{ label: 'Vencidos', value: vencidosCount, total: true, isVencidos: true },
|
||||
];
|
||||
---
|
||||
<AppLayout title="Estadísticas — LabPréstamos">
|
||||
<div class="max-w-6xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl md:text-3xl font-semibold">Estadísticas</h1>
|
||||
<p class="text-sm mt-1 first-letter:uppercase" style="color: var(--color-pencil);">
|
||||
<h1 class="text-3xl md:text-4xl">Estadísticas</h1>
|
||||
<p class="text-sm mt-1 opacity-70">
|
||||
Actividad del día · <time datetime={dia.isoDate}>{dia.label}</time>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Métricas del día" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
|
||||
{kpis.map((k) => (
|
||||
<div class="card p-4">
|
||||
<div class="text-xs" style="color: var(--color-pencil);">{k.label}{k.total ? ' (total)' : ' hoy'}</div>
|
||||
<div class="text-3xl font-semibold mt-1 leading-none" style={`font-variant-numeric: tabular-nums; ${toneStyle(k.tone)}`}>
|
||||
{k.value}
|
||||
{kpis.map((k) => {
|
||||
const isVencidosAlert = k.isVencidos && k.value > 0;
|
||||
return (
|
||||
<div
|
||||
class:list={[
|
||||
'flex flex-col justify-between',
|
||||
isVencidosAlert ? 'card-dark' : 'card',
|
||||
]}
|
||||
>
|
||||
<div class:list={['eyebrow', isVencidosAlert ? 'text-amarillo-dorado' : 'text-dorado-uabc']}>
|
||||
{k.label}{k.total ? ' (total)' : ' hoy'}
|
||||
</div>
|
||||
<div
|
||||
class:list={[
|
||||
'text-3xl font-light mt-2 leading-none tabular-nums',
|
||||
isVencidosAlert ? 'text-bone-white' : 'text-graphite',
|
||||
]}
|
||||
>
|
||||
{k.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Solicitudes por día · últimos {DIAS}</h3>
|
||||
<h3 class="mb-4">Solicitudes por día · últimos {DIAS} días</h3>
|
||||
{solicitudesPorDia.some((d) => d.valor > 0) ? (
|
||||
<Chart tipo="linea" datos={solicitudesPorDia} client:idle />
|
||||
) : (
|
||||
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Sin actividad en los últimos {DIAS} días.</p>
|
||||
<p class="text-sm py-8 text-center text-graphite/60">Sin actividad en los últimos {DIAS} días.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Distribución de estados</h3>
|
||||
<h3 class="mb-4">Distribución de estados</h3>
|
||||
{distribEstados.length > 0 ? (
|
||||
<Chart tipo="donut" datos={distribEstados} client:idle />
|
||||
) : (
|
||||
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Aún no hay solicitudes registradas.</p>
|
||||
<p class="text-sm py-8 text-center text-graphite/60">Aún no hay solicitudes registradas.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="card lg:col-span-2">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">Top 10 materiales · últimos {DIAS} días</h3>
|
||||
<h3 class="mb-4">Top 10 materiales · últimos {DIAS} días</h3>
|
||||
{topMateriales.length > 0 ? (
|
||||
<Chart tipo="barras" datos={topMateriales} alto={320} client:idle />
|
||||
) : (
|
||||
<p class="text-sm py-8 text-center" style="color: var(--color-pencil);">Ningún material solicitado en los últimos {DIAS} días.</p>
|
||||
<p class="text-sm py-8 text-center text-graphite/60">Ningún material solicitado en los últimos {DIAS} días.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user