544bbf38a9
Buscador catálogo alumno: - BuscadorCatalogo.tsx con debounce 120ms, normaliza tildes, ?q= en URL - FiltroCategorias usa data-* attributes, coordinación via window.__labreFiltrar - Cards con data-nombre y data-numero-inventario para filtro combinable AND Grid + imágenes + unidades individuales (admin): - Inventario ahora es grid 2/3/4/5 col con fotos aspect-square - MaterialForm: input file con preview, checkbox trackeado_por_unidad - UnidadesManager: agregar/editar/eliminar unidades por material - Reasignar unidad desde detalles de solicitud (aprobada/activa) - Alumno ve etiqueta de unidad asignada en mis-préstamos - Helper materialImg.imgUrl(path) para URL pública del bucket - Auto-submit debounced del filtro de inventario (input + selects) Estadísticas separada: - Nueva ruta /admin/estadisticas con 6 KPIs (movidos de /admin/inventario) - 3 gráficas recharts: top 10 materiales, solicitudes/día, distribución estados - Chart.tsx wrapper con paleta UABC + respeta prefers-reduced-motion - Nav admin: Panel · Solicitudes · Inventario (box) · Estadísticas (stats) · Reportes - admin/index.astro usa todayMX() (fix inconsistencia) Notificaciones pendientes (admin): - Badge SSR con conteo de pendientes sobre ícono Solicitudes (sidebar + dock) - BadgeSolicitudes.tsx: canal Realtime en prestamos.solicitudes, +1 en INSERT con toast, refetch en UPDATE (replica identity default no trae old.estado) - Recharts añadido a package.json
165 lines
6.8 KiB
Plaintext
165 lines
6.8 KiB
Plaintext
---
|
|
import AppLayout from '@/layouts/AppLayout.astro';
|
|
import Chart from '@/components/admin/estadisticas/Chart.tsx';
|
|
import { todayMX } from '@/lib/date';
|
|
|
|
// 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),
|
|
]);
|
|
|
|
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).
|
|
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,
|
|
}));
|
|
---
|
|
<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);">
|
|
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}
|
|
</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>
|
|
{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>
|
|
)}
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 class="text-sm font-semibold uppercase tracking-wide mb-3">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>
|
|
)}
|
|
</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>
|
|
{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>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</AppLayout>
|