196 lines
7.2 KiB
JavaScript
196 lines
7.2 KiB
JavaScript
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 <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>
|
|
);
|
|
}
|