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

197 lines
7.2 KiB
JavaScript
Raw Permalink Normal View History

2026-09-21 23:11:53 +00:00
import { auth } from "../lib/auth";
import { ownerFilter } from "../lib/authz";
import { getClosed, getSystemUsers } from "../lib/queries";
import { agg, EJECUTIVOS_VENTA } from "../lib/derive";
import { getMetas, 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";
function currentMonth() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function Panel({ title, hint, rows, cls }) {
const maxN = Math.max(...rows.map((r) => r.n), 1);
return (
<div className="panel">
<h3>{title}</h3>
{hint && <p className="hint">{hint}</p>}
{rows.map((r) => (
<div className="row dual" key={r.k}>
<div className="bl">
<span className="nm" title={r.k}>{r.k}</span>
</div>
<div className="bar-cell">
<div className={`bar-fill ${cls || ""}`} style={{ width: `${((r.n / maxN) * 100).toFixed(1)}%` }} />
</div>
<span className="v">{r.n}</span>
<span className="c">{money(r.v)}</span>
</div>
))}
</div>
);
}
export default async function GanadasPage({ searchParams }) {
const session = await auth();
if (!session?.email) return <LoginPrompt />;
const { owner } = await searchParams;
const scope = ownerFilter(session, owner);
const mes = currentMonth();
let won, lost, allUsers;
try {
[won, lost, allUsers] = await Promise.all([
getClosed(session.accessToken, scope, 1, mes),
getClosed(session.accessToken, scope, 2, mes),
getSystemUsers(session.accessToken),
]);
} catch (error) {
return <ErrorNotice error={error} />;
}
const owners = allUsers.filter((u) => EJECUTIVOS_VENTA.includes(u.name));
const metas = getMetas();
const metaGlobal = getMetaGlobal();
const sumW = won.reduce((a, o) => a + (o.actual || 0), 0);
const arpu = won.length ? sumW / won.length : 0;
const conv = won.length + lost.length ? Math.round((won.length / (won.length + lost.length)) * 100) : 0;
const cumplGlobal = metaGlobal ? Math.round((sumW / metaGlobal) * 100) : null;
const porEjecutivo = EJECUTIVOS_VENTA.map((nombre) => {
const w = won.filter((o) => o.owner === nombre);
const logrado = w.reduce((a, o) => a + (o.actual || 0), 0);
const email = allUsers.find((u) => u.name === nombre)?.email;
const meta = email ? metas[email] : null;
const pct = meta ? Math.round((logrado / meta) * 100) : null;
return { nombre, logrado, meta, pct };
}).sort((a, b) => (b.pct ?? -1) - (a.pct ?? -1));
return (
<div style={{ padding: 24 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
<h2>Ventas Ganadas {mes}</h2>
<OwnerControl
owners={owners}
current={scope.locked ? session.email : scope.email}
locked={scope.locked}
lockedLabel={session.email}
/>
</div>
<div className="kpis">
<div className="kpi">
<span className="n">{money2(sumW)}</span>
<span className="l">Suma ingresos reales</span>
</div>
<div className="kpi">
<span className="n">{won.length}</span>
<span className="l">Oportunidades ganadas</span>
</div>
<div className="kpi">
<span className="n">{money2(arpu)}</span>
<span className="l">ARPU logrado</span>
</div>
<div className="kpi">
<span className="n">{conv}%</span>
<span className="l">% Conversión</span>
</div>
<div className="kpi">
<span className={cumplGlobal != null && cumplGlobal < 70 ? "n pierde" : "n"}>
{cumplGlobal != null ? `${cumplGlobal}%` : "n/d"}
</span>
<span className="l">Cumplimiento equipo</span>
</div>
</div>
<div className="panel">
<h3>Cumplimiento de meta por ejecutivo</h3>
<p className="hint">Ingresos reales ÷ meta mensual · verde 70% · ámbar 50-69% · rojo &lt;50%</p>
{porEjecutivo.map((r) => (
<div className="row static" key={r.nombre}>
<div className="bl">
<span className="nm">{r.nombre}</span>
</div>
<div className="bar-cell">
<div
className={`bar-fill ${r.pct == null ? "" : r.pct >= 70 ? "" : r.pct >= 50 ? "alerta" : "pierde"}`}
style={{ width: `${Math.min(100, r.pct ?? 0)}%` }}
/>
</div>
<span className="v">{r.pct == null ? "n/d" : `${r.pct}%`}</span>
<span className="c">
{money(r.logrado)}
{r.meta ? `/${money(r.meta)}` : ""}
</span>
</div>
))}
</div>
<div className="grid2">
<Panel title="Por Ejecutivo" rows={agg(won, "owner", (o) => o.actual)} />
<Panel title="Por Tipo de Oportunidad" rows={agg(won, "tipo", (o) => o.actual)} />
<Panel title="Por Línea de Negocio" rows={agg(won, "linea", (o) => o.actual)} />
<Panel title="Por Origen" rows={agg(won, "origen", (o) => o.actual)} />
</div>
<div className="panel" style={{ paddingBottom: 6 }}>
<h3>Detalle de oportunidades ganadas</h3>
<p className="hint">Clic en el nombre abre la oportunidad en Dynamics 365</p>
</div>
{won.length === 0 ? (
<p>No hay oportunidades ganadas en este período para este filtro.</p>
) : (
<div className="tw">
<div className="ts">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Oportunidad</th>
<th>Seg.</th>
{!scope.locked && <th>Ejecutivo</th>}
<th>Tipo</th>
<th>Línea de Negocio</th>
<th>Origen</th>
<th className="num">MRR</th>
<th>Cierre</th>
</tr>
</thead>
<tbody>
{[...won].sort((a, b) => b.actual - a.actual).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>
<span className={`pill ${o.segmento === "Cartera Activa" ? "cartera" : "nuevo"}`}>
{o.segmento === "Cartera Activa" ? "CA" : "CN"}
</span>
</td>
{!scope.locked && <td>{o.owner}</td>}
<td>{o.tipo}</td>
<td>{o.linea}</td>
<td>{o.origen}</td>
<td className="num">{money2(o.actual)}</td>
<td>{o.actualClose || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}