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

162 lines
6 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, 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>
);
}