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 ;
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 ;
}
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 (
Previsión de Ventas
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.length}
Oportunidades en esta ventana
{money(est)}
Valor estimado (MRR)
{money(fc)}
Forecast ponderado
{coverage != null ? `${coverage}%` : "n/d"}
Coverage vs meta equipo ({money(metaGlobal)})
{estanc.length}
En riesgo (estancadas)
Forecast ponderado = Σ (MRR estimado × probabilidad del Plan de Cierre). 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.
{Object.entries(groups).some(([, v]) => v.length > 0) && (
Oportunidades para accionar esta semana
Clasificado por palabras clave en "Estado de la Negociación" de Dynamics.
)}
{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 (
{BLOCKER_INFO[key].label} — {list.length} oport. · {money(gEst)} est. · {money(gFc)} ponderado
{BLOCKER_INFO[key].accion}
| Cliente |
Oportunidad |
Ejecutivo |
MRR |
Nota de negociación |
{[...list].sort((a, b) => b.est - a.est).map((o) => (
| {o.cliente || "—"} |
{o.name}
|
{o.owner} |
{money2(o.est)} |
{(o.estado || "").slice(0, 100)}
{(o.estado || "").length > 100 ? "…" : ""}
|
))}
);
})}
Forecast por Plan de Cierre
{agg(p, "plan", (o) => o.fc).map((r) => (
{r.k}
x.n), 1)) * 100).toFixed(1)}%` }}
/>
{r.n}
{money(r.v)}
))}
Por Ejecutivo
{agg(p, "owner", (o) => o.est).map((r) => (
{r.k}
x.n), 1)) * 100).toFixed(1)}%` }}
/>
{r.n}
{money(r.v)}
))}
Oportunidades con cierre estimado en esta ventana
Forecast = MRR × peso del plan · clic abre Dynamics
{p.length === 0 ? (
No hay oportunidades en esta ventana.
) : (
| Cliente |
Oportunidad |
Plan cierre |
Ejecutivo |
MRR est. |
Forecast |
Cierre est. |
{[...p].sort((a, b) => b.fc - a.fc).map((o) => (
| {o.cliente || "—"} |
{o.name}
|
{(o.plan || "").replace(" - ", " ")} |
{o.owner} |
{money2(o.est)} |
{money2(o.fc)} |
{o.close || "—"} |
))}
)}
);
}