161 lines
6 KiB
JavaScript
161 lines
6 KiB
JavaScript
import { auth } from "../lib/auth";
|
|
import { ownerFilter } from "../lib/authz";
|
|
import { getClosed, getMotivosCierre, getSystemUsers } from "../lib/queries";
|
|
import { agg, EJECUTIVOS_VENTA } from "../lib/derive";
|
|
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 }) {
|
|
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 pierde" 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 PerdemosPage({ searchParams }) {
|
|
const session = await auth();
|
|
if (!session?.email) return <LoginPrompt />;
|
|
|
|
const { owner } = await searchParams;
|
|
const scope = ownerFilter(session, owner);
|
|
const mes = currentMonth();
|
|
|
|
let lost, allUsers;
|
|
try {
|
|
[lost, allUsers] = await Promise.all([
|
|
getClosed(session.accessToken, scope, 2, mes),
|
|
getSystemUsers(session.accessToken),
|
|
]);
|
|
const motivos = await getMotivosCierre(lost.map((o) => o.id), session.accessToken);
|
|
lost = lost.map((o) => ({ ...o, motivo: motivos[o.id]?.motivo || "Sin registrar", competidor: motivos[o.id]?.competidor }));
|
|
} catch (error) {
|
|
return <ErrorNotice error={error} />;
|
|
}
|
|
const owners = allUsers.filter((u) => EJECUTIVOS_VENTA.includes(u.name));
|
|
|
|
const mrrPerdido = lost.reduce((a, o) => a + (o.est || 0), 0);
|
|
const clientesNuevos = lost.filter((o) => o.segmento === "Clientes Nuevos").length;
|
|
const carteraActiva = lost.filter((o) => o.segmento === "Cartera Activa").length;
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
|
|
<h2>Por qué Perdemos — {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 pierde">{lost.length}</span>
|
|
<span className="l">Oportunidades perdidas</span>
|
|
</div>
|
|
<div className="kpi">
|
|
<span className="n pierde">{money(mrrPerdido)}</span>
|
|
<span className="l">MRR estimado perdido</span>
|
|
</div>
|
|
<div className="kpi">
|
|
<span className="n">{clientesNuevos}</span>
|
|
<span className="l">Clientes Nuevos</span>
|
|
</div>
|
|
<div className="kpi">
|
|
<span className="n">{carteraActiva}</span>
|
|
<span className="l">Cartera Activa</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="hint">
|
|
No hay seguimiento registrado en el CRM ≠ no hubo seguimiento — la mayoría del contacto comercial se hace por
|
|
WhatsApp, que no está integrado a Dynamics (ver CLAUDE.md).
|
|
</p>
|
|
|
|
<div className="grid2">
|
|
<Panel
|
|
title="Motivo de cierre"
|
|
hint='Fuente: Cierre de oportunidad en Dynamics · "Sin registrar" = ningún motivo (Alfa ni estándar) quedó guardado'
|
|
rows={agg(lost, "motivo", (o) => o.est)}
|
|
/>
|
|
<Panel title="Competidor" rows={agg(lost.filter((o) => o.competidor && o.competidor !== "Ninguno"), "competidor", (o) => o.est)} />
|
|
<Panel title="Servicio / Producto" rows={agg(lost, "servicio", (o) => o.est)} />
|
|
<Panel title="Origen de la oportunidad" rows={agg(lost, "origen", (o) => o.est)} />
|
|
</div>
|
|
|
|
<div className="panel" style={{ paddingBottom: 6 }}>
|
|
<h3>Detalle de oportunidades perdidas</h3>
|
|
<p className="hint">Clic en el nombre abre la oportunidad en Dynamics 365</p>
|
|
</div>
|
|
{lost.length === 0 ? (
|
|
<p>No hay oportunidades perdidas 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>
|
|
<th>Motivo de cierre</th>
|
|
<th>Línea de Negocio</th>
|
|
<th>Competidor</th>
|
|
{!scope.locked && <th>Ejecutivo</th>}
|
|
<th className="num">MRR est.</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[...lost].sort((a, b) => (b.est || 0) - (a.est || 0)).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>
|
|
<td>{o.motivo}</td>
|
|
<td>{o.linea || "—"}</td>
|
|
<td>{o.competidor || "—"}</td>
|
|
{!scope.locked && <td>{o.owner}</td>}
|
|
<td className="num">{o.est ? money2(o.est) : "—"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|