Files
labre-web/src/pages/admin/estadisticas.astro
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

92 lines
3.5 KiB
Plaintext

---
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();
// Cache 60s en RAM (getEstadisticas absorbe >90% de visitas repetidas).
const { kpis: k, topMateriales, solicitudesPorDia, distribEstados } = await getEstadisticas(supabase, dia);
// Debe coincidir con la ventana en stats-cache.ts (rango de las 3 gráficas).
const DIAS = 30;
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-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) => {
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>
);
})}
</section>
<section aria-label="Análisis" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card">
<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 text-graphite/60">Sin actividad en los últimos {DIAS} días.</p>
)}
</div>
<div class="card">
<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 text-graphite/60">Aún no hay solicitudes registradas.</p>
)}
</div>
<div class="card lg:col-span-2">
<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 text-graphite/60">Ningún material solicitado en los últimos {DIAS} días.</p>
)}
</div>
</section>
</div>
</AppLayout>