282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
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>
|
||
);
|
||
}
|