Contracts — Analítica de clínica (solo lectura)
Sin tablas nuevas. Un endpoint de lectura + firmas puras + página. Zona Europe/Madrid, textos es-ES, dinero en céntimos. Toda agregación acotada a clinicaId de la sesión (FR-001); redefinir tiempo fuera de 006 es defecto (006 FR-010).
1. GET /api/analitica (solo lectura)
Auth: misma cookie de panel que agenda (clinicaDeLaPeticion / exigirClinica). Sin clave ⇒ 401 SIN_ACCESO («Entra con la clave de la clínica para continuar»).
GET /api/analitica HTTP/1.1
Cookie: citaclara_clinica=<sesión>
200 OK — application/json (forma AnaliticaClinica de data-model.md §3)
{
"clinica": { "id": "uuid", "nombre": "Eleva", "jornada": "09:00–20:00, Lun–Vie" },
"ventana": { "semanas": [{ "n": 8, "etiqueta": "S-8: 03/08/2026–07/08/2026" }], "generadaCon": "2026-09-30T10:00:00.000Z" },
"tasas": [{ "profesionalId": "uuid", "nombre": "María", "tasa": 7.3, "texto": "7,3 %", "partes": { "noAsistidas": 11, "total": 150 } }],
"ocupaciones": [{ "profesionalId": "uuid", "nombre": "María", "ocupacion": 25.1, "minutos": 6615, "denominador": 26400 }],
"ingresos": { "porServicio": [{ "servicioId": "uuid", "nombre": "sesión fisio", "citas": 126, "centimos": 504000, "texto": "5.040,00 €" }], "totalCentimos": 1435500, "totalTexto": "14.355,00 €" },
"evolucion": [{ "n": 8, "etiqueta": "S-8: 03/08/2026–07/08/2026", "citas": 48, "completadas": 41, "noAsistidas": 2, "canceladas": 5, "ingresosCentimos": 175000, "ingresosTexto": "1.750,00 €" }],
"sinDesenlace": 0
}
Errores: 401 SIN_ACCESO sin sesión; 405 METODO_NO_PERMITIDO para POST/PATCH/PUT/DELETE («Esta página es de solo lectura»); nunca 500 por clínica sin historia (devuelve ceros + «Aún no hay datos suficientes» en UI).
2. lib/analitica.ts (puro, testeable sin BD)
import type { SemanaEtiquetada } from './ventanas';
export type CitaAnalitica = {
profesionalId: string;
servicioId: string;
inicio: Date;
fin: Date;
estado: 'reservada' | 'completada' | 'cancelada' | 'no_asistida';
precioCongeladoCentimos: number;
};
export type ProfesionalAnalitica = { id: string; nombre: string };
export type ServicioAnalitica = { id: string; nombre: string };
/** FR-002. null = sin citas («—», no 0 %). Reservadas siempre fuera. */
export function tasaNoAsistencia(citas: CitaAnalitica[]): {
tasa: number | null;
texto: string;
noAsistidas: number;
total: number;
};
/** FR-003. Minutos reales fin−inicio de completadas / denominador vigente (006 FR-008). */
export function ocupacionSemanal(
citas: CitaAnalitica[],
denominadorMin: number,
): { ocupacion: number; minutos: number };
/** FR-004. Solo completadas × precio congelado; resto 0 €. Céntimos enteros. */
export function ingresosPorServicio(citas: CitaAnalitica[]): { citas: number; centimos: number };
/** FR-005. 8 filas S-8…S-1; curso y futuras fuera; etiquetas derivadas. */
export function evolucionSemanal(
citas: CitaAnalitica[],
semanas: SemanaEtiquetada[],
): {
n: number;
etiqueta: string;
citas: number;
completadas: number;
noAsistidas: number;
canceladas: number;
ingresosCentimos: number;
}[];
/** Foto coherente: tasas + ocupaciones + ingresos + evolución + sinDesenlace. Cero escritura. */
export function calcularAnalitica(entrada: {
citas: CitaAnalitica[];
profesionales: ProfesionalAnalitica[];
servicios: ServicioAnalitica[];
semanas: SemanaEtiquetada[];
denominadorMin: number;
}): {
tasas: {
profesionalId: string;
nombre: string;
tasa: number | null;
texto: string;
noAsistidas: number;
total: number;
}[];
ocupaciones: {
profesionalId: string;
nombre: string;
ocupacion: number;
minutos: number;
denominador: number;
}[];
ingresos: {
porServicio: { servicioId: string; nombre: string; citas: number; centimos: number }[];
totalCentimos: number;
};
evolucion: {
n: number;
etiqueta: string;
citas: number;
completadas: number;
noAsistidas: number;
canceladas: number;
ingresosCentimos: number;
}[];
sinDesenlace: number;
};
Reglas vinculantes: (1) ahora único por cálculo (capturarAhora, 006 FR-001); (2) denominador siempre vía denominadorOcupacion vigente, prohibido 26.400 literal fuera de tests-oráculo; (3) importes solo con sumarCentimos/formatearEuros; (4) etiquetas S-N derivadas de semanasAnalitica, nunca constantes; (5) cero prisma.$executeRaw de escritura en este módulo.
3. Página app/(panel)/analitica/page.tsx
Server Component force-dynamic; sin sesión ⇒ redirect('/acceso'). Muestra las 4 métricas como gráficos SVG (components/grafico-barras.tsx) con eje + valores, letra ≥ 16 px, contraste verificado, cero jerga, todo es-ES. Añade enlace en app/(panel)/layout.tsx («Analítica»). Sin formularios ni botones que muten.