informe-comercial-alfa/app/prevision/page.js

283 lines
11 KiB
JavaScript
Raw Normal View History

2026-09-21 23:11:53 +00:00
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>
);
}