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).

{atrasadas.length > 0 && ( )} {futuros.map((m) => ( ))}
{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}

{[...list].sort((a, b) => b.est - a.est).map((o) => ( ))}
Cliente Oportunidad Ejecutivo MRR Nota de negociación
{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.

) : (
{[...p].sort((a, b) => b.fc - a.fc).map((o) => ( ))}
Cliente Oportunidad Plan cierre Ejecutivo MRR est. Forecast Cierre est.
{o.cliente || "—"} {o.name} {(o.plan || "").replace(" - ", " ")} {o.owner} {money2(o.est)} {money2(o.fc)} {o.close || "—"}
)}
); }