informe-comercial-alfa/app/prevision/page.js
2026-09-21 17:11:53 -06:00

282 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { auth } from "../lib/auth";
import { ownerFilter } from "../lib/authz";
import { getPipeline, getSystemUsers } from "../lib/queries";
import { agg, EJECUTIVOS_VENTA } from "../lib/derive";
import { getMetaGlobal } from "../lib/metas";
import LoginPrompt from "../components/LoginPrompt";
import OwnerControl from "../components/OwnerControl";
import ErrorNotice from "../components/ErrorNotice";
import { DYN, money, money2 } from "../lib/constants";
export const dynamic = "force-dynamic";
const PREVIS_TIPOS = ["Venta - Cliente Nuevo", "Up-Selling", "Cross-Selling"];
const PREVIS_PLANES_EXCL = ["Sin plan de cierre", "Plan D - Recalificar"];
const ATRASADAS = "__ATRASADAS__";
const BLOCKER_INFO = {
precio: { label: "Precio / oferta no competitiva", accion: "Evaluar un acelerador puntual: descuento, mes de cortesía o ajuste de instalación." },
aprobacion: { label: "Esperando aprobación interna del cliente", accion: "Escalar con una llamada ejecutivo a gerente para destrabar la firma." },
licitacion: { label: "Proceso de licitación externo", accion: "Fuera de control directo por ahora — mantener la relación." },
};
function classifyBlocker(txt) {
txt = (txt || "").toLowerCase();
if (/licitaci|licitatorio/.test(txt)) return "licitacion";
if (/precio|tarifa|descuento|presupuesto|\bcost[oa]\b|\bcara\b|instalaci[oó]n/.test(txt)) return "precio";
if (/aprobaci|aprobar|aprob[oó]|gerencia|gerente|\bfirma\b|firmar|comit[eé]/.test(txt)) return "aprobacion";
return null;
}
function currentMonth() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export default async function PrevisionPage({ searchParams }) {
const session = await auth();
if (!session?.email) return <LoginPrompt />;
const { owner, w: winParam } = await searchParams;
const scope = ownerFilter(session, owner);
const hoy = currentMonth();
let allRows, allUsers;
try {
[allRows, allUsers] = await Promise.all([
getPipeline(session.accessToken, scope),
session.isAdmin ? getSystemUsers(session.accessToken) : Promise.resolve([]),
]);
} catch (error) {
return <ErrorNotice error={error} />;
}
const owners = allUsers.filter((u) => EJECUTIVOS_VENTA.includes(u.name));
const base = allRows.filter((o) => PREVIS_TIPOS.includes(o.tipo) && !PREVIS_PLANES_EXCL.includes(o.plan));
const futuros = [...new Set(base.map((o) => o.mesCierre).filter((m) => m && m >= hoy))].sort();
const atrasadas = base.filter((o) => !o.mesCierre || o.mesCierre < hoy);
const win = winParam === ATRASADAS ? ATRASADAS : futuros.includes(winParam) ? winParam : futuros[0] || hoy;
const p = win === ATRASADAS ? atrasadas : base.filter((o) => o.mesCierre === win);
const est = p.reduce((a, o) => a + o.est, 0);
const fc = p.reduce((a, o) => a + o.fc, 0);
const estanc = p.filter((o) => o.estanc);
const metaGlobal = getMetaGlobal();
const coverage = metaGlobal ? Math.round((fc / metaGlobal) * 100) : null;
const groups = { precio: [], aprobacion: [], licitacion: [] };
p.forEach((o) => {
const cat = classifyBlocker(o.estado);
if (cat) groups[cat].push(o);
});
const linkFor = (w) => {
const params = new URLSearchParams();
if (owner) params.set("owner", owner);
params.set("w", w);
return `/prevision?${params.toString()}`;
};
return (
<div style={{ padding: 24 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
<h2>Previsión de Ventas</h2>
<OwnerControl
owners={owners}
current={scope.locked ? session.email : scope.email}
locked={scope.locked}
lockedLabel={session.email}
/>
</div>
<p className="hint" style={{ marginBottom: 8 }}>
Solo considera oportunidades con Plan de Cierre A, B o C (se excluyen "Sin plan de cierre" y "Plan D -
Recalificar" por no tener compromiso de cierre real).
</p>
<div className="seg-tabs" style={{ marginBottom: 16, flexWrap: "wrap" }}>
{atrasadas.length > 0 && (
<a href={linkFor(ATRASADAS)}>
<button className={win === ATRASADAS ? "on" : ""} type="button">
Atrasadas ({atrasadas.length})
</button>
</a>
)}
{futuros.map((m) => (
<a key={m} href={linkFor(m)}>
<button className={win === m ? "on" : ""} type="button">
{m}
</button>
</a>
))}
</div>
<div className="kpis">
<div className="kpi">
<span className="n">{p.length}</span>
<span className="l">Oportunidades en esta ventana</span>
</div>
<div className="kpi">
<span className="n">{money(est)}</span>
<span className="l">Valor estimado (MRR)</span>
</div>
<div className="kpi">
<span className="n">{money(fc)}</span>
<span className="l">Forecast ponderado</span>
</div>
<div className="kpi">
<span className={coverage != null && coverage < 100 ? "n pierde" : "n"}>
{coverage != null ? `${coverage}%` : "n/d"}
</span>
<span className="l">Coverage vs meta equipo ({money(metaGlobal)})</span>
</div>
<div className="kpi">
<span className="n pierde">{estanc.length}</span>
<span className="l">En riesgo (estancadas)</span>
</div>
</div>
<div className="callout warn">
<b>Forecast ponderado = Σ (MRR estimado × probabilidad del Plan de Cierre).</b> Pesos: Plan A 90% · Plan B
60% · Plan C 30%. Este número no castiga la antigüedad una oferta de hace 5 días pesa igual que una de 144
días. Cruzar siempre con "Días en fase" antes de darlo por seguro.
</div>
{Object.entries(groups).some(([, v]) => v.length > 0) && (
<div className="panel" style={{ marginTop: 18 }}>
<h3>Oportunidades para accionar esta semana</h3>
<p className="hint">Clasificado por palabras clave en "Estado de la Negociación" de Dynamics.</p>
</div>
)}
{Object.entries(groups)
.filter(([, list]) => list.length > 0)
.map(([key, list]) => {
const gEst = list.reduce((a, o) => a + o.est, 0);
const gFc = list.reduce((a, o) => a + o.fc, 0);
return (
<div className="panel" style={{ marginBottom: 14 }} key={key}>
<h3>
{BLOCKER_INFO[key].label} {list.length} oport. · {money(gEst)} est. · {money(gFc)} ponderado
</h3>
<p className="hint">{BLOCKER_INFO[key].accion}</p>
<div className="tw">
<div className="ts">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Oportunidad</th>
<th>Ejecutivo</th>
<th className="num">MRR</th>
<th>Nota de negociación</th>
</tr>
</thead>
<tbody>
{[...list].sort((a, b) => b.est - a.est).map((o) => (
<tr key={o.id}>
<td>{o.cliente || "—"}</td>
<td>
<a className="opp" href={`${DYN}${o.id}`} target="_blank" rel="noopener">
{o.name}
</a>
</td>
<td>{o.owner}</td>
<td className="num">{money2(o.est)}</td>
<td style={{ color: "var(--tinta-suave)", fontSize: 12 }}>
{(o.estado || "").slice(0, 100)}
{(o.estado || "").length > 100 ? "…" : ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
})}
<div className="grid2" style={{ marginTop: 18 }}>
<div className="panel">
<h3>Forecast por Plan de Cierre</h3>
{agg(p, "plan", (o) => o.fc).map((r) => (
<div className="row static" key={r.k}>
<div className="bl">
<span className="nm">{r.k}</span>
</div>
<div className="bar-cell">
<div
className="bar-fill"
style={{ width: `${((r.n / Math.max(...agg(p, "plan").map((x) => x.n), 1)) * 100).toFixed(1)}%` }}
/>
</div>
<span className="v">{r.n}</span>
<span className="c">{money(r.v)}</span>
</div>
))}
</div>
<div className="panel">
<h3>Por Ejecutivo</h3>
{agg(p, "owner", (o) => o.est).map((r) => (
<div className="row dual" key={r.k}>
<div className="bl">
<span className="nm">{r.k}</span>
</div>
<div className="bar-cell">
<div
className="bar-fill"
style={{ width: `${((r.n / Math.max(...agg(p, "owner").map((x) => x.n), 1)) * 100).toFixed(1)}%` }}
/>
</div>
<span className="v">{r.n}</span>
<span className="c">{money(r.v)}</span>
</div>
))}
</div>
</div>
<div className="panel" style={{ paddingBottom: 6, marginTop: 18 }}>
<h3>Oportunidades con cierre estimado en esta ventana</h3>
<p className="hint">Forecast = MRR × peso del plan · clic abre Dynamics</p>
</div>
{p.length === 0 ? (
<p>No hay oportunidades en esta ventana.</p>
) : (
<div className="tw">
<div className="ts">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Oportunidad</th>
<th>Plan cierre</th>
<th>Ejecutivo</th>
<th className="num">MRR est.</th>
<th className="num">Forecast</th>
<th>Cierre est.</th>
</tr>
</thead>
<tbody>
{[...p].sort((a, b) => b.fc - a.fc).map((o) => (
<tr key={o.id}>
<td>{o.cliente || "—"}</td>
<td>
<a className="opp" href={`${DYN}${o.id}`} target="_blank" rel="noopener">
{o.name}
</a>
</td>
<td>{(o.plan || "").replace(" - ", " ")}</td>
<td>{o.owner}</td>
<td className="num">{money2(o.est)}</td>
<td className="num">{money2(o.fc)}</td>
<td>{o.close || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}