Files
fspm-web/src/components/admin/graficas/SerieTiempo.tsx
T
Uriel JarethandClaude Opus 5 01b817d098 feat: sitio comercial FSPM con micro CRM interno
Sitio público orientado a venta consultiva e implementación de las
recomendaciones de la auditoría de marca y mercado:

- Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador
  de riesgo en el héroe, contra el hallazgo de que el visitante tenía que
  inferir qué solución le correspondía.
- Cinco páginas por segmento prioritario y catálogo comparable por clase
  de fuego, capacidad y agente.
- Los claims sin evidencia por modelo quedan aislados y se presentan como
  compromiso de documentación, nunca como característica del producto.
- SEO técnico completo: metadatos por página, sitemap derivado de los
  datos, JSON-LD, iconos y tarjeta social generadas.

Micro CRM:

- Captura por formulario embebido y por widget de WhatsApp que registra
  antes de abrir la conversación.
- Atribución de primer toque que sobrevive a la navegación.
- Deduplicación de contacto; un mismo contacto acumula oportunidades.
- Motor de asignación por reglas con reparto por turno de respaldo.
- Panel con dos roles de alcance distinto, tablero analítico, bitácora,
  pedidos y reglas. No enlazado desde el sitio público y con noindex.

Empaquetado en Docker multietapa con la base de demostración horneada en
la imagen y el volumen de datos separado, para que los leads capturados
sobrevivan a los redespliegues.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 00:07:20 -06:00

359 lines
12 KiB
TypeScript

"use client";
import { useMemo, useRef, useState } from "react";
import type { PuntoSerie } from "@/lib/crm/tipos";
import { entero, fecha, fechaCorta } from "@/components/admin/formato";
import { EstadoVacio } from "@/components/admin/ui";
import {
COLOR_GANADAS,
COLOR_OPORTUNIDADES,
COLOR_RETICULA,
COLOR_SUPERFICIE,
} from "@/components/admin/graficas/paleta";
/**
* Serie de tiempo de oportunidades y ganadas. SVG escrito a mano, sin librerías.
*
* La geometría vive en un `viewBox` de 0-100 en los dos ejes con
* `preserveAspectRatio="none"`: así el trazo se estira con el contenedor sin
* romperse —los trazos llevan `vector-effect="non-scaling-stroke"`, de modo que
* 2 px siguen siendo 2 px— y todo el texto es HTML real, que no se encoge a
* tamaños ilegibles en un teléfono de 320 px.
*
* Accesible: `role="img"` con un resumen del dato, `<figcaption>` con las
* cifras clave y una tabla equivalente para lector de pantalla. La capa de
* cruceta y globo enriquece; nunca es el único camino al número.
*/
const ALTO_TRAZO = 2;
function maximoLimpio(v: number): number {
if (v <= 4) return 4;
const magnitud = Math.pow(10, Math.floor(Math.log10(v)));
for (const paso of [1, 2, 2.5, 5, 10]) {
const candidato = paso * magnitud;
if (candidato >= v) return candidato;
}
return 10 * magnitud;
}
export function SerieTiempo({ serie, dias }: { serie: PuntoSerie[]; dias: number }) {
const cajaRef = useRef<HTMLDivElement>(null);
const [activo, setActivo] = useState<number | null>(null);
const modelo = useMemo(() => {
const maxDato = serie.reduce((m, p) => Math.max(m, p.oportunidades, p.ganadas), 0);
const max = maximoLimpio(maxDato);
const n = serie.length;
const x = (i: number) => (n <= 1 ? 50 : (i / (n - 1)) * 100);
const y = (v: number) => 100 - (v / max) * 100;
return {
max,
x,
y,
totalOportunidades: serie.reduce((s, p) => s + p.oportunidades, 0),
totalGanadas: serie.reduce((s, p) => s + p.ganadas, 0),
pico: serie.reduce(
(mejor, p) => (p.oportunidades > mejor.oportunidades ? p : mejor),
serie[0] ?? { fecha: "", oportunidades: 0, ganadas: 0 },
),
};
}, [serie]);
if (serie.length === 0) {
return (
<EstadoVacio
titulo="Serie sin datos"
quehacer={`No hubo envíos en los últimos ${dias} días. Amplía el periodo a 90 días o revisa que la captura del sitio esté publicando en el CRM.`}
/>
);
}
const { max, x, y, totalOportunidades, totalGanadas, pico } = modelo;
const lineaOportunidades = serie
.map((p, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(3)},${y(p.oportunidades).toFixed(3)}`)
.join(" ");
const areaOportunidades = `${lineaOportunidades} L${x(serie.length - 1).toFixed(3)},100 L${x(0).toFixed(3)},100 Z`;
const lineaGanadas = serie
.map((p, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(3)},${y(p.ganadas).toFixed(3)}`)
.join(" ");
const resumen = `Serie de ${dias} días: ${entero(totalOportunidades)} oportunidades y ${entero(
totalGanadas,
)} ganadas. El día más alto fue ${fecha(pico.fecha)} con ${entero(pico.oportunidades)} oportunidades.`;
const marcas = [0, 0.25, 0.5, 0.75, 1];
const indicesX =
serie.length <= 4
? serie.map((_, i) => i)
: [0, Math.round((serie.length - 1) / 2), serie.length - 1];
function alMover(e: React.PointerEvent<HTMLDivElement>) {
const caja = cajaRef.current;
if (!caja || serie.length === 0) return;
const r = caja.getBoundingClientRect();
if (r.width === 0) return;
const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
setActivo(Math.round(frac * (serie.length - 1)));
}
function alTecla(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
const base = activo ?? (e.key === "ArrowRight" ? -1 : serie.length);
const siguiente = Math.min(
serie.length - 1,
Math.max(0, base + (e.key === "ArrowRight" ? 1 : -1)),
);
setActivo(siguiente);
}
if (e.key === "Escape") setActivo(null);
}
const punto = activo != null ? serie[activo] : null;
return (
<figure className="m-0 px-4 py-4 sm:px-5">
<Leyenda />
<div className="relative min-w-0 pl-11 pr-2">
<div
ref={cajaRef}
tabIndex={0}
role="img"
aria-label={resumen}
onPointerMove={alMover}
onPointerLeave={() => setActivo(null)}
onKeyDown={alTecla}
onBlur={() => setActivo(null)}
className="relative h-44 w-full min-w-0 cursor-crosshair rounded-[2px] sm:h-56"
style={{ WebkitTapHighlightColor: "transparent", touchAction: "pan-y" }}
>
{/* rótulos del eje Y, en el canal reservado por el padre */}
{marcas.map((m) => (
<span
key={m}
aria-hidden="true"
className="absolute -left-11 w-10 -translate-y-1/2 text-right font-mono text-[0.5625rem] tabular-nums text-faint"
style={{ top: `${(1 - m) * 100}%` }}
>
{entero(max * m)}
</span>
))}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
className="absolute inset-0 block h-full w-full overflow-visible"
>
{/* retícula: filete sólido de 1 px, un paso fuera de la superficie */}
{marcas.map((m) => (
<line
key={m}
x1="0"
x2="100"
y1={(1 - m) * 100}
y2={(1 - m) * 100}
stroke={COLOR_RETICULA}
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
))}
{/* oportunidades: lavado del 10 % más trazo de 2 px */}
<path d={areaOportunidades} fill={COLOR_OPORTUNIDADES} fillOpacity="0.1" />
<path
d={lineaOportunidades}
fill="none"
stroke={COLOR_OPORTUNIDADES}
strokeWidth={ALTO_TRAZO}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{/* ganadas */}
<path
d={lineaGanadas}
fill="none"
stroke={COLOR_GANADAS}
strokeWidth={ALTO_TRAZO}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{activo != null ? (
<line
x1={x(activo)}
x2={x(activo)}
y1="0"
y2="100"
stroke="var(--color-rule-2)"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
) : null}
</svg>
{/* puntas y punto activo: HTML, para que el círculo no se deforme */}
<Punto
izquierda={x(serie.length - 1)}
arriba={y(serie[serie.length - 1].oportunidades)}
color={COLOR_OPORTUNIDADES}
/>
<Punto
izquierda={x(serie.length - 1)}
arriba={y(serie[serie.length - 1].ganadas)}
color={COLOR_GANADAS}
/>
{activo != null && punto ? (
<>
<Punto izquierda={x(activo)} arriba={y(punto.oportunidades)} color={COLOR_OPORTUNIDADES} />
<Punto izquierda={x(activo)} arriba={y(punto.ganadas)} color={COLOR_GANADAS} />
</>
) : null}
</div>
{/* eje X */}
<div className="relative mt-2 h-4">
{indicesX.map((i) => (
<span
key={i}
aria-hidden="true"
className="absolute font-mono text-[0.5625rem] uppercase tracking-[0.06em] text-faint"
style={{
left: `${x(i)}%`,
transform:
i === 0
? "translateX(0)"
: i === serie.length - 1
? "translateX(-100%)"
: "translateX(-50%)",
}}
>
{fechaCorta(serie[i].fecha)}
</span>
))}
</div>
{/* globo: el valor manda, el nombre de la serie acompaña */}
{punto ? (
<div
aria-hidden="true"
className="pointer-events-none absolute top-1 z-10 min-w-[9rem] rounded-[3px] border border-rule-2 bg-ink px-2.5 py-2 shadow-lg"
style={{
left: `${Math.min(78, Math.max(0, x(activo ?? 0)))}%`,
}}
>
<p className="mb-1.5 font-mono text-[0.5625rem] uppercase tracking-[0.1em] text-faint">
{fecha(punto.fecha)}
</p>
<FilaGlobo color={COLOR_OPORTUNIDADES} etiqueta="Oportunidades" valor={punto.oportunidades} />
<FilaGlobo color={COLOR_GANADAS} etiqueta="Ganadas" valor={punto.ganadas} />
</div>
) : null}
</div>
<figcaption className="mt-3 text-xs leading-relaxed text-muted">
{entero(totalOportunidades)} oportunidades y {entero(totalGanadas)} ganadas en{" "}
{dias} días. Día más alto: {fecha(pico.fecha)} con {entero(pico.oportunidades)}.
</figcaption>
<table className="sr-only">
<caption>Oportunidades y ganadas por día</caption>
<thead>
<tr>
<th scope="col">Día</th>
<th scope="col">Oportunidades</th>
<th scope="col">Ganadas</th>
</tr>
</thead>
<tbody>
{serie.map((p) => (
<tr key={p.fecha}>
<th scope="row">{fecha(p.fecha)}</th>
<td>{entero(p.oportunidades)}</td>
<td>{entero(p.ganadas)}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
function Punto({
izquierda,
arriba,
color,
}: {
izquierda: number;
arriba: number;
color: string;
}) {
return (
<span
aria-hidden="true"
className="pointer-events-none absolute block h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{
left: `${izquierda}%`,
top: `${arriba}%`,
backgroundColor: color,
boxShadow: `0 0 0 2px ${COLOR_SUPERFICIE}`,
}}
/>
);
}
function FilaGlobo({
color,
etiqueta,
valor,
}: {
color: string;
etiqueta: string;
valor: number;
}) {
return (
<p className="flex items-baseline gap-2">
<span
aria-hidden="true"
className="mb-[0.2em] block h-0.5 w-3 shrink-0 self-center rounded-full"
style={{ backgroundColor: color }}
/>
<span className="font-mono text-sm font-semibold text-bright">{entero(valor)}</span>
<span className="font-mono text-[0.5625rem] uppercase tracking-[0.08em] text-muted">
{etiqueta}
</span>
</p>
);
}
function Leyenda() {
return (
<ul className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<ItemLeyenda color={COLOR_OPORTUNIDADES} etiqueta="Oportunidades" />
<ItemLeyenda color={COLOR_GANADAS} etiqueta="Ganadas" />
</ul>
);
}
function ItemLeyenda({ color, etiqueta }: { color: string; etiqueta: string }) {
return (
<li className="flex items-center gap-2">
<span
aria-hidden="true"
className="block h-0.5 w-4 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-muted">
{etiqueta}
</span>
</li>
);
}