initial deploy: informe-comercial-alfa

This commit is contained in:
MarcoMontenegro 2026-09-21 17:11:53 -06:00
commit ad0585ed85
36 changed files with 3258 additions and 0 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
node_modules
.next
.git
data
__nm_old
.nm_trash*
*.log
npm-debug.log*
.env
.env.local

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules
.next
data
__nm_old
*.log
.DS_Store
.env
.env.local

13
Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
# native modules on Alpine (musl) must be compiled
RUN apk add --no-cache python3 make g++
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["npm", "start"]

101
app/admin/metas/page.js Normal file
View file

@ -0,0 +1,101 @@
import { revalidatePath } from "next/cache";
import { auth } from "../../lib/auth";
import { getSystemUsers } from "../../lib/queries";
import { getMetas, getMetaGlobal, setMeta, setMetaGlobal } from "../../lib/metas";
import { EJECUTIVOS_VENTA } from "../../lib/derive";
import LoginPrompt from "../../components/LoginPrompt";
export const dynamic = "force-dynamic";
export default async function AdminMetasPage() {
const session = await auth();
if (!session?.email) return <LoginPrompt />;
if (!session.isAdmin) {
return (
<div style={{ padding: 24 }}>
<h2>Metas</h2>
<p>Esta página es solo para administradores.</p>
</div>
);
}
const allUsers = await getSystemUsers(session.accessToken);
const metas = getMetas();
const metaGlobal = getMetaGlobal();
const vendedoras = EJECUTIVOS_VENTA.map((nombre) => {
const u = allUsers.find((x) => x.name === nombre);
return { nombre, email: u?.email || null, monto: u ? metas[u.email] || 0 : 0 };
});
async function guardarMeta(formData) {
"use server";
const email = formData.get("email");
const monto = Number(formData.get("monto"));
if (email && monto >= 0) setMeta(email, monto);
revalidatePath("/admin/metas");
revalidatePath("/");
revalidatePath("/ganadas");
revalidatePath("/prevision");
revalidatePath("/tendencia");
}
async function guardarGlobal(formData) {
"use server";
const monto = Number(formData.get("metaGlobal"));
if (monto >= 0) setMetaGlobal(monto);
revalidatePath("/admin/metas");
revalidatePath("/");
revalidatePath("/ganadas");
revalidatePath("/prevision");
revalidatePath("/tendencia");
}
return (
<div style={{ padding: 24 }}>
<h2>Metas mensuales</h2>
<p className="hint">
Solo administradores. Se guardan en SQLite (sobreviven a cada publicación, ver Etapa 4 en migration.md).
</p>
<div className="panel" style={{ maxWidth: 420 }}>
<h3>Meta global del equipo</h3>
<form action={guardarGlobal} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input name="metaGlobal" type="number" step="1" min="0" defaultValue={metaGlobal} style={{ width: 120 }} />
<button type="submit" className="login-btn">
Guardar
</button>
</form>
</div>
<div className="panel" style={{ marginTop: 16, maxWidth: 520 }}>
<h3>Meta por ejecutiva</h3>
{vendedoras.map((v) => (
<div
key={v.nombre}
style={{
display: "flex",
gap: 8,
alignItems: "center",
padding: "8px 0",
borderBottom: "1px solid var(--linea)",
}}
>
<span style={{ width: 220, flexShrink: 0 }}>{v.nombre}</span>
{v.email ? (
<form action={guardarMeta} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="hidden" name="email" value={v.email} />
<input name="monto" type="number" step="1" min="0" defaultValue={v.monto} style={{ width: 100 }} />
<button type="submit" className="login-btn">
Guardar
</button>
</form>
) : (
<span style={{ color: "var(--pierde)" }}>No encontrada en systemusers</span>
)}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,2 @@
import { handlers } from "../../../lib/auth";
export const { GET, POST } = handlers;

View file

@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
export default function AppShell({ pages, session, onSignIn, onSignOut, children }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
return (
<div className="app">
<aside className={`side${open ? " open" : ""}`}>
<div className="side-logo">
<Image src="/assets/img/logo-alfa-blanco.png" alt="ALFA+" width={100} height={27} />
<div className="sub">Analítica Comercial</div>
</div>
<nav className="nav">
{pages.map((p) =>
p.built ? (
<Link
key={p.href}
href={p.href}
className={`nav-item${pathname === p.href ? " active" : ""}`}
style={{ textDecoration: "none" }}
onClick={() => setOpen(false)}
>
<span className="ico">{p.ico}</span>
{p.label}
</Link>
) : (
<div key={p.href} className="nav-item soon" title="Próximamente">
<span className="ico">{p.ico}</span>
{p.label}
<span className="tag">Pronto</span>
</div>
)
)}
</nav>
<div className="side-foot">
Datos: Dynamics 365
<br />
En vivo
</div>
</aside>
<div className={`backdrop${open ? " open" : ""}`} onClick={() => setOpen(false)} />
<div className="main">
<div className="topbar">
<button className="hamburger" aria-label="Menú" onClick={() => setOpen((o) => !o)} type="button">
</button>
<div className="ctrl" style={{ marginLeft: "auto" }}>
{session?.email ? (
<>
{session.isAdmin && (
<Link href="/admin/metas" style={{ marginRight: 12, fontSize: 13, color: "var(--marca)" }}>
Metas
</Link>
)}
<span style={{ marginRight: 12 }}>
{session.email} {session.isAdmin ? "(admin)" : ""}
</span>
<form action={onSignOut}>
<button type="submit" className="login-btn">
Cerrar sesión
</button>
</form>
</>
) : (
<form action={onSignIn}>
<button type="submit" className="login-btn">
Iniciar sesión
</button>
</form>
)}
</div>
</div>
<main className="content">{children}</main>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
export default function ErrorNotice({ error }) {
const isExpired = error?.message === "TOKEN_EXPIRED" || error?.message?.includes("RefreshFailed");
return (
<div style={{ padding: 24 }}>
<h2>No se pudo cargar la información</h2>
{isExpired ? (
<p>Tu sesión con Dynamics expiró. Cerrá sesión y volvé a iniciarla.</p>
) : (
<p>
Dynamics no respondió correctamente (posiblemente por límite de consultas). Recargá la página en unos
segundos.
</p>
)}
<p style={{ color: "#9db4c9", fontSize: 12 }}>{error?.message}</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
export default function LoginPrompt() {
return (
<div style={{ padding: 40, textAlign: "center" }}>
<h2>Informe Comercial ALFA+</h2>
<p>Iniciá sesión con tu cuenta de Alfanumeric para ver tu información.</p>
</div>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
export default function OwnerControl({ owners, current, locked, lockedLabel }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
if (locked) {
return (
<div className="ctrl owner-ctrl">
<label>Propietario</label>
<button type="button" className="owner-btn" disabled>
{lockedLabel}
</button>
</div>
);
}
function onChange(e) {
const params = new URLSearchParams(searchParams.toString());
if (e.target.value) params.set("owner", e.target.value);
else params.delete("owner");
const qs = params.toString();
router.push(qs ? `${pathname}?${qs}` : pathname);
}
return (
<div className="ctrl owner-ctrl">
<label>Propietario</label>
<select value={current || ""} onChange={onChange}>
<option value="">Todos los propietarios</option>
{owners.map((o) => (
<option key={o.id} value={o.email}>
{o.name}
</option>
))}
</select>
</div>
);
}

139
app/detalle/page.js Normal file
View file

@ -0,0 +1,139 @@
import Link from "next/link";
import { auth } from "../lib/auth";
import { ownerFilter } from "../lib/authz";
import { getPipeline, getClosed } from "../lib/queries";
import LoginPrompt from "../components/LoginPrompt";
import ErrorNotice from "../components/ErrorNotice";
import { DYN, ACT_COLOR, 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")}`;
}
const SETS = [
{ key: "pipe", label: "Tubería activa" },
{ key: "ganadas", label: "Ganadas" },
{ key: "perdidas", label: "Perdidas" },
];
export default async function DetallePage({ searchParams }) {
const session = await auth();
if (!session?.email) return <LoginPrompt />;
const { owner, set } = await searchParams;
const scope = ownerFilter(session, owner);
const detSet = SETS.some((s) => s.key === set) ? set : "pipe";
const mes = currentMonth();
let rows;
try {
if (detSet === "pipe") rows = await getPipeline(session.accessToken, scope);
else if (detSet === "ganadas") rows = await getClosed(session.accessToken, scope, 1, mes);
else rows = await getClosed(session.accessToken, scope, 2, mes);
} catch (error) {
return <ErrorNotice error={error} />;
}
const sorted = [...rows].sort((a, b) => (b.est ?? b.actual ?? 0) - (a.est ?? a.actual ?? 0));
const qs = (key) => {
const p = new URLSearchParams();
if (owner) p.set("owner", owner);
p.set("set", key);
return `/detalle?${p.toString()}`;
};
return (
<div style={{ padding: 24 }}>
<h2>Detalle</h2>
<div className="seg-tabs" style={{ marginBottom: 16 }}>
{SETS.map((s) => (
<Link key={s.key} href={qs(s.key)} className={detSet === s.key ? "on" : ""}>
<button className={detSet === s.key ? "on" : ""} type="button">
{s.label}
</button>
</Link>
))}
</div>
<p className="hint" style={{ marginBottom: 12 }}>
{rows.length} oportunidades{detSet !== "pipe" ? ` (${mes})` : ""} · clic en el nombre abre la oportunidad en
Dynamics 365.
</p>
{sorted.length === 0 ? (
<p>No hay oportunidades 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>}
{detSet === "pipe" ? (
<>
<th>Plan cierre</th>
<th>Fase actual</th>
<th>Actividad</th>
<th className="num">MRR est.</th>
<th className="num">Forecast</th>
</>
) : (
<>
<th>Tipo</th>
<th className="num">MRR</th>
<th>Cierre</th>
</>
)}
</tr>
</thead>
<tbody>
{sorted.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>}
{detSet === "pipe" ? (
<>
<td>{(o.plan || "").replace(" - ", " ")}</td>
<td>{o.faseActual || "—"}</td>
<td>
<span style={{ color: ACT_COLOR[o.actividad] || "#9db4c9", fontWeight: 700 }}>
{o.actividad}
</span>
</td>
<td className="num">{money2(o.est)}</td>
<td className="num">{money2(o.fc)}</td>
</>
) : (
<>
<td>{o.tipo}</td>
<td className="num">{money2(o.actual)}</td>
<td>{o.actualClose || "—"}</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

34
app/diagnostico/page.js Normal file
View file

@ -0,0 +1,34 @@
import { auth } from "../lib/auth";
import { dv } from "../lib/dataverse";
import LoginPrompt from "../components/LoginPrompt";
export const dynamic = "force-dynamic";
async function checkDataverse(accessToken) {
try {
const data = await dv("systemusers?$select=fullname&$top=1", accessToken);
return { ok: true, sample: data.value?.[0]?.fullname };
} catch (e) {
return { ok: false, error: e.message };
}
}
export default async function DiagnosticoPage() {
const session = await auth();
if (!session?.email) return <LoginPrompt />;
const check = session.accessToken ? await checkDataverse(session.accessToken) : { ok: false, error: "sin accessToken" };
return (
<div style={{ padding: 24 }}>
<h2>Diagnóstico</h2>
<ul>
<li><strong>Correo:</strong> {session.email}</li>
<li><strong>Rol:</strong> {session.isAdmin ? "Administrador" : "Vendedor"}</li>
<li><strong>Token de Dataverse:</strong> {check.ok ? " funciona" : ` ${check.error}`}</li>
{check.ok && <li><strong>Prueba de lectura:</strong> {check.sample}</li>}
{session.error && <li style={{ color: "red" }}><strong>Error de sesión:</strong> {session.error}</li>}
</ul>
</div>
);
}

196
app/ganadas/page.js Normal file
View file

@ -0,0 +1,196 @@
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>
);
}

10
app/globals.css Normal file
View file

@ -0,0 +1,10 @@
.login-btn {
font-family: var(--sans);
font-size: 13px;
padding: 7px 14px;
border: 1px solid var(--marca);
border-radius: 6px;
background: var(--marca);
color: #fff;
cursor: pointer;
}

54
app/layout.js Normal file
View file

@ -0,0 +1,54 @@
import "./globals.css";
import { auth, signIn, signOut } from "./lib/auth";
import AppShell from "./components/AppShell";
export const metadata = {
title: "Informe Comercial ALFA+",
description: "Analítica comercial en vivo sobre Dynamics 365",
};
const PAGES = [
{ href: "/", label: "Resumen Ejecutivo", ico: "▣", built: true },
{ href: "/tendencia", label: "Tendencia / Caída", ico: "📈", built: true },
{ href: "/ganadas", label: "Ventas Ganadas", ico: "▲", built: true },
{ href: "/perdemos", label: "Por qué Perdemos", ico: "▼", built: true },
{ href: "/tuberia", label: "Tubería Activa", ico: "▤", built: true },
{ href: "/prevision", label: "Previsión de Ventas", ico: "◎", built: true },
{ href: "/mapa", label: "Mapa Geográfico", ico: "◈", built: false },
{ href: "/precio", label: "Precio por Mbps", ico: "⊟", built: false },
{ href: "/detalle", label: "Detalle", ico: "☰", built: true },
];
export default async function RootLayout({ children }) {
const session = await auth();
async function handleSignIn() {
"use server";
await signIn("microsoft-entra-id");
}
async function handleSignOut() {
"use server";
await signOut();
}
return (
<html lang="es">
<head>
<link rel="stylesheet" href="/assets/css/base.css" />
<link rel="stylesheet" href="/assets/css/layout.css" />
<link rel="stylesheet" href="/assets/css/components.css" />
</head>
<body>
<AppShell
pages={PAGES}
session={session?.email ? { email: session.email, isAdmin: session.isAdmin } : null}
onSignIn={handleSignIn}
onSignOut={handleSignOut}
>
{children}
</AppShell>
</body>
</html>
);
}

72
app/lib/auth.js Normal file
View file

@ -0,0 +1,72 @@
import NextAuth from "next-auth";
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
const DV = process.env.DATAVERSE_URL; // https://<org>.crm.dynamics.com
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
providers: [
MicrosoftEntraID({
clientId: process.env.AZURE_CLIENT_ID,
clientSecret: process.env.AZURE_CLIENT_SECRET,
issuer: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0`,
authorization: {
params: {
scope: `openid profile email offline_access ${DV}/user_impersonation`,
},
},
}),
],
callbacks: {
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
}
if (token.expiresAt && Date.now() / 1000 > token.expiresAt - 300 && token.refreshToken) {
try {
const r = await fetch(
`https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.AZURE_CLIENT_ID,
client_secret: process.env.AZURE_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
scope: `openid profile email offline_access ${DV}/user_impersonation`,
}),
}
);
if (r.ok) {
const d = await r.json();
token.accessToken = d.access_token;
token.expiresAt = Math.floor(Date.now() / 1000) + d.expires_in;
if (d.refresh_token) token.refreshToken = d.refresh_token;
} else {
token.error = "RefreshFailed";
}
} catch {
token.error = "RefreshFailed";
}
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken;
session.error = token.error;
const email = (session.user?.email || "").toLowerCase();
session.email = email;
session.isAdmin = (process.env.ADMIN_EMAILS || "")
.toLowerCase()
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.includes(email);
return session;
},
},
session: { strategy: "jwt" },
});

9
app/lib/authz.js Normal file
View file

@ -0,0 +1,9 @@
export function ownerFilter(session, requestedOwnerEmail) {
if (!session?.email) throw new Error("NO_SESSION");
// Vendedor: siempre su propio correo, ignorando lo que pida el cliente.
if (!session.isAdmin) return { email: session.email, locked: true };
// Admin: puede pedir un vendedor concreto, o todos.
return { email: requestedOwnerEmail || null, locked: false };
}

14
app/lib/constants.js Normal file
View file

@ -0,0 +1,14 @@
export const DYN =
"https://alfanumeric.crm.dynamics.com/main.aspx?appid=eb8b70d7-5a69-e911-a998-000d3a1a42fe&pagetype=entityrecord&etn=opportunity&id=";
export const ACT_COLOR = {
Activa: "#3f7d5c",
Tibia: "#C98A1B",
Fría: "#d1642a",
Congelada: "#B23A2E",
"Sin dato": "#9db4c9",
};
export const money = (n) => "$" + Math.round(n || 0).toLocaleString("en-US");
export const money2 = (n) =>
"$" + (n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });

38
app/lib/dataverse.js Normal file
View file

@ -0,0 +1,38 @@
const BASE = `${process.env.DATAVERSE_URL}/api/data/v9.2`;
const MAX_RETRIES = 3;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Reintenta 429 (throttling de Dataverse) respetando Retry-After — ver migration.md §8.
export async function dv(path, accessToken, attempt = 0) {
const r = await fetch(`${BASE}/${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"OData-MaxVersion": "4.0",
"OData-Version": "4.0",
Prefer: 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"',
},
cache: "no-store", // decisión: sin caché, datos en vivo
});
if (r.status === 401) throw new Error("TOKEN_EXPIRED");
if (r.status === 429 && attempt < MAX_RETRIES) {
const retryAfter = Number(r.headers.get("Retry-After")) || 2 ** attempt;
await sleep(retryAfter * 1000);
return dv(path, accessToken, attempt + 1);
}
if (!r.ok) throw new Error(`DATAVERSE_${r.status}: ${(await r.text()).slice(0, 300)}`);
return r.json();
}
// Etiqueta legible de un option set, entregada por el header Prefer de arriba.
export function fv(o, key) {
return o[`${key}@OData.Community.Display.V1.FormattedValue`] || null;
}
export function odataString(s) {
return String(s).replace(/'/g, "''");
}

35
app/lib/db.js Normal file
View file

@ -0,0 +1,35 @@
import Database from "better-sqlite3";
import path from "node:path";
import fs from "node:fs";
// process.cwd() es /app dentro del contenedor (WORKDIR /app en el Dockerfile),
// donde Coolify monta el volumen persistente en /app/data.
function createDb() {
const dir = path.join(process.cwd(), "data");
fs.mkdirSync(dir, { recursive: true });
const db = new Database(path.join(dir, "informe.db"));
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS metas (
email TEXT PRIMARY KEY,
monto REAL NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS config (
clave TEXT PRIMARY KEY,
valor TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS geo_override (
opportunity_id TEXT PRIMARY KEY,
lat REAL,
lon REAL,
zona TEXT,
depto TEXT
);
`);
return db;
}
const g = globalThis;
const db = g.__informeDb ?? (g.__informeDb = createDb());
export default db;

75
app/lib/derive.js Normal file
View file

@ -0,0 +1,75 @@
// Cálculos portados de herramientas/sync_pipe.js (ETL estático) — misma lógica, servidor en vivo.
export const CARTERA = ["Up-Selling", "Cross-Selling", "Plan de Cuenta"];
export const PESO_PLAN = {
"Plan A - En firma": 0.9,
"Plan B - En Negociación": 0.6,
"Plan C - En seguimiento": 0.3,
"Plan D - Recalificar": 0.15,
"Sin plan de cierre": 0.1,
};
// Los 4 tipos que cuentan para cumplimiento de meta (ver CLAUDE.md — se excluyen renovaciones y SAC).
export const TIPOS_META = ["Venta - Cliente Nuevo", "Up-Selling", "Cross-Selling", "Plan de Cuenta"];
// Ejecutivos de venta reales (ver CLAUDE.md). Gabriela Sánchez y Ashley Bermúdez aparecen
// en los datos de Dynamics pero no son ejecutivas de venta — se excluyen del selector.
export const EJECUTIVOS_VENTA = [
"Mirna Obando Morras",
"Maria Auxiliadora Marenco Abea",
"Denis Urroz Manzanares",
"Daybellys Perez Sequeira",
"Martha Somarriba Sirias",
];
export function segmentoDe(tipo) {
return CARTERA.includes(tipo) ? "Cartera Activa" : "Clientes Nuevos";
}
// Agrupa arr por o[key], contando y sumando valfn(o). Ordena por conteo descendente.
export function agg(arr, key, valfn) {
const m = new Map();
arr.forEach((o) => {
const k = o[key] || "(sin dato)";
if (!m.has(k)) m.set(k, { n: 0, v: 0 });
const e = m.get(k);
e.n++;
e.v += valfn ? valfn(o) : 0;
});
return [...m.entries()].map(([k, x]) => ({ k, ...x })).sort((a, b) => b.n - a.n);
}
export function bucketActividad(diasSinActividad) {
if (diasSinActividad == null) return "Sin dato";
if (diasSinActividad <= 14) return "Activa";
if (diasSinActividad <= 30) return "Tibia";
if (diasSinActividad <= 60) return "Fría";
return "Congelada";
}
// row: objeto ya con etiquetas resueltas (tipo, plan como texto, no option-set numérico).
// faseInfo: { fase, started } | undefined, de alfa_stagedurations.
// today: Date, para calcular días en fase de forma determinística.
export function derivePipelineRow(row, faseInfo, today) {
const plan = row.plan || "Sin plan de cierre";
const peso = PESO_PLAN[plan] ?? 0.1;
const est = row.est || 0;
const diasEnFase = faseInfo?.started
? Math.round((today - new Date(faseInfo.started)) / 86400000)
: null;
const estanc = (row.dsa != null && row.dsa > 14) || (diasEnFase != null && diasEnFase > 30);
return {
...row,
plan,
peso,
fc: +(est * peso).toFixed(2),
estanc,
faseActual: faseInfo?.fase || null,
diasEnFase,
actividad: bucketActividad(row.dsa),
segmento: segmentoDe(row.tipo),
mesCierre: row.close ? row.close.slice(0, 7) : null,
};
}

34
app/lib/metas.js Normal file
View file

@ -0,0 +1,34 @@
import db from "./db";
// Metas mensuales — SQLite (Etapa 4). Clave: correo del vendedor, resuelto en vivo
// contra systemusers (no hay una lista fija de correos confirmados — ver migration.md §11.5).
// La tabla arranca vacía: un administrador las carga desde /admin/metas.
export function getMetas() {
const rows = db.prepare("SELECT email, monto FROM metas").all();
const map = {};
rows.forEach((r) => {
map[r.email] = r.monto;
});
return map;
}
export function getMetaGlobal() {
const row = db.prepare("SELECT valor FROM config WHERE clave = 'meta_global'").get();
if (row) return Number(row.valor);
return Object.values(getMetas()).reduce((a, v) => a + v, 0);
}
export function setMeta(email, monto) {
db.prepare(
`INSERT INTO metas (email, monto, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(email) DO UPDATE SET monto = excluded.monto, updated_at = excluded.updated_at`
).run(email.toLowerCase(), monto);
}
export function setMetaGlobal(monto) {
db.prepare(
`INSERT INTO config (clave, valor) VALUES ('meta_global', ?)
ON CONFLICT(clave) DO UPDATE SET valor = excluded.valor`
).run(String(monto));
}

182
app/lib/queries.js Normal file
View file

@ -0,0 +1,182 @@
import { dv, fv, odataString } from "./dataverse";
import { derivePipelineRow, segmentoDe } from "./derive";
const OPP_SELECT = [
"opportunityid",
"name",
"_ownerid_value",
"alfa_typeopportunity",
"alfa_tipodeventa",
"_alfa_lineadenegocioid_value",
"_alfa_servicioproductoid_value",
"estimatedvalue",
"actualvalue",
"alfa_plandecierre",
"alfa_envejecimiento",
"alfa_diassinactividad",
"estimatedclosedate",
"actualclosedate",
"stepname",
"alfa_estadodelanegociacion",
"_customerid_value",
].join(",");
// Tubería: 5 tipos de la cartera "en pipeline" (ver ALFA_Estructura_Oportunidades_CRM.md).
const PIPE_TYPE_FILTER =
"(alfa_typeopportunity eq 100000000 or alfa_typeopportunity eq 100000003 or " +
"alfa_typeopportunity eq 100000004 or alfa_typeopportunity eq 100000007 or alfa_typeopportunity eq 100000009)";
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
export async function getSystemUsers(accessToken) {
const data = await dv(
"systemusers?$select=systemuserid,fullname,internalemailaddress&$filter=isdisabled eq false",
accessToken
);
return (data.value || []).map((u) => ({
id: u.systemuserid,
name: u.fullname,
email: (u.internalemailaddress || "").toLowerCase(),
}));
}
export async function resolveOwnerId(email, accessToken) {
if (!email) return null;
const data = await dv(
`systemusers?$select=systemuserid&$filter=${encodeURIComponent(
`internalemailaddress eq '${odataString(email)}'`
)}`,
accessToken
);
return data.value?.[0]?.systemuserid || null;
}
function mapOpportunityRow(o) {
const tipo = fv(o, "alfa_typeopportunity");
return {
id: o.opportunityid,
name: o.name,
owner: fv(o, "_ownerid_value"),
tipo,
segmento: segmentoDe(tipo),
origen: fv(o, "alfa_tipodeventa"),
linea: fv(o, "_alfa_lineadenegocioid_value"),
servicio: fv(o, "_alfa_servicioproductoid_value"),
est: o.estimatedvalue || 0,
actual: o.actualvalue || 0,
plan: fv(o, "alfa_plandecierre"),
env: o.alfa_envejecimiento ?? null,
dsa: o.alfa_diassinactividad ?? null,
close: o.estimatedclosedate ? o.estimatedclosedate.slice(0, 10) : null,
actualClose: o.actualclosedate ? o.actualclosedate.slice(0, 10) : null,
step: o.stepname,
estado: o.alfa_estadodelanegociacion || null,
cliente: fv(o, "_customerid_value"),
};
}
async function getFaseActual(oppIds, accessToken) {
const fase = {};
for (const batch of chunk(oppIds, 150)) {
if (!batch.length) continue;
const conditions = batch.map((id) => `<value>${id}</value>`).join("");
const fetchXml =
`<fetch><entity name="alfa_stageduration">` +
`<attribute name="alfa_subject"/><attribute name="alfa_started"/><attribute name="alfa_opportunity"/>` +
`<filter><condition attribute="statuscode" operator="eq" value="1"/>` +
`<condition attribute="alfa_opportunity" operator="in">${conditions}</condition></filter>` +
`</entity></fetch>`;
const data = await dv(`alfa_stagedurations?fetchXml=${encodeURIComponent(fetchXml)}`, accessToken);
for (const r of data.value || []) {
const oid = r["_alfa_opportunity_value"] || r.alfa_opportunity;
const started = r.alfa_started;
const subj = (r.alfa_subject || "").replace("Fase: ", "");
if (!fase[oid] || (started && started > fase[oid].started)) {
fase[oid] = { fase: subj, started };
}
}
}
return fase;
}
// Motivo/competidor de cierre — viven en opportunityclose, no en opportunity (ver
// contexto_tabla_cierre_oportunidad.md). alfa_motivodecierre es el campo principal (Alfa);
// opportunitystatuscode es el respaldo estándar — en la práctica muchos cierres recientes
// solo llenan el estándar. Nos quedamos con el cierre más reciente por oportunidad.
export async function getMotivosCierre(oppIds, accessToken) {
const motivos = {};
for (const batch of chunk(oppIds, 150)) {
if (!batch.length) continue;
const conditions = batch.map((id) => `<value>${id}</value>`).join("");
const fetchXml =
`<fetch><entity name="opportunityclose">` +
`<attribute name="opportunityid"/><attribute name="alfa_motivodecierre"/>` +
`<attribute name="opportunitystatuscode"/><attribute name="alfa_competitor"/>` +
`<attribute name="createdon"/>` +
`<filter><condition attribute="opportunityid" operator="in">${conditions}</condition></filter>` +
`</entity></fetch>`;
const data = await dv(`opportunitycloses?fetchXml=${encodeURIComponent(fetchXml)}`, accessToken);
for (const r of data.value || []) {
const oid = r["_opportunityid_value"];
const created = r.createdon;
if (motivos[oid] && motivos[oid].createdon >= created) continue;
motivos[oid] = {
createdon: created,
motivo: fv(r, "alfa_motivodecierre") || fv(r, "opportunitystatuscode") || null,
competidor: fv(r, "_alfa_competitor_value"),
};
}
}
return motivos;
}
// scope: { email, locked } de ownerFilter(). email null (admin, "todos") no filtra por propietario.
export async function getPipeline(accessToken, scope) {
let filter = `statecode eq 0 and estimatedclosedate ne null and msdyn_forecastcategory ne 100000001 and ${PIPE_TYPE_FILTER}`;
if (scope.email) {
const ownerId = await resolveOwnerId(scope.email, accessToken);
if (!ownerId) return [];
filter += ` and _ownerid_value eq ${ownerId}`;
}
const data = await dv(
`opportunities?$select=${OPP_SELECT}&$filter=${encodeURIComponent(filter)}&$top=1000`,
accessToken
);
const rows = (data.value || []).map(mapOpportunityRow);
const faseMap = await getFaseActual(rows.map((r) => r.id), accessToken);
const today = new Date();
return rows.map((r) => derivePipelineRow(r, faseMap[r.id], today));
}
// Resuelve una sola vez el systemuserid del scope (o null si no aplica) — para reusar en varias
// llamadas a getClosed sin repetir la consulta a systemusers cada vez (ej. Tendencia, 6 meses).
export async function resolveScopeOwnerId(scope, accessToken) {
if (!scope.email) return { none: true };
const ownerId = await resolveOwnerId(scope.email, accessToken);
return { none: false, ownerId };
}
// statecode: 1 = ganadas, 2 = perdidas. month: "YYYY-MM", filtra por el mes de cierre real.
// ownerId: opcional, ya resuelto vía resolveScopeOwnerId — evita resolver el email en cada llamada.
export async function getClosed(accessToken, scope, statecode, month, resolvedOwnerId) {
const monthStart = `${month}-01`;
const [y, m] = month.split("-").map(Number);
const nextMonth = new Date(y, m, 1).toISOString().slice(0, 10);
let filter =
`statecode eq ${statecode} and actualclosedate ge ${monthStart} and actualclosedate lt ${nextMonth}`;
if (scope.email) {
const ownerId = resolvedOwnerId !== undefined ? resolvedOwnerId : await resolveOwnerId(scope.email, accessToken);
if (!ownerId) return [];
filter += ` and _ownerid_value eq ${ownerId}`;
}
const data = await dv(
`opportunities?$select=${OPP_SELECT}&$filter=${encodeURIComponent(filter)}&$top=1000`,
accessToken
);
return (data.value || []).map(mapOpportunityRow);
}

99
app/page.js Normal file
View file

@ -0,0 +1,99 @@
import { auth } from "./lib/auth";
import { ownerFilter } from "./lib/authz";
import { getClosed, getSystemUsers } from "./lib/queries";
import { TIPOS_META, 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 { 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")}`;
}
export default async function Home({ 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 sumW = won.reduce((a, o) => a + (o.actual || 0), 0);
const sumWMeta = won
.filter((o) => TIPOS_META.includes(o.tipo))
.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 metas = getMetas();
const viewedEmail = scope.locked ? session.email : scope.email;
const metaDen = viewedEmail ? metas[viewedEmail.toLowerCase()] || null : getMetaGlobal();
const cumpl = metaDen ? Math.round((sumWMeta / metaDen) * 100) : null;
return (
<div style={{ padding: 24 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
<h2>Resumen {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">Ingresos reales (ganadas)</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 pierde">{lost.length}</span>
<span className="l">Oportunidades perdidas</span>
</div>
<div className="kpi">
<span className="n">{conv}%</span>
<span className="l">% Conversión (ganadas/total)</span>
</div>
<div className="kpi">
<span className={cumpl != null && cumpl < 70 ? "n pierde" : "n"}>
{cumpl != null ? cumpl + "%" : "n/d"}
</span>
<span className="l">
Cumplimiento de meta{metaDen ? ` (${money2(metaDen)})` : " (sin meta asignada)"}
</span>
</div>
</div>
<p className="hint">
Cumplimiento de meta cuenta solo Venta - Cliente Nuevo, Up-Selling, Cross-Selling y Plan de Cuenta (se
excluyen renovaciones y SAC).
</p>
</div>
);
}

161
app/perdemos/page.js Normal file
View file

@ -0,0 +1,161 @@
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>
);
}

282
app/prevision/page.js Normal file
View file

@ -0,0 +1,282 @@
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 <LoginPrompt />;
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 <ErrorNotice error={error} />;
}
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 (
<div style={{ padding: 24 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
<h2>Previsión de Ventas</h2>
<OwnerControl
owners={owners}
current={scope.locked ? session.email : scope.email}
locked={scope.locked}
lockedLabel={session.email}
/>
</div>
<p className="hint" style={{ marginBottom: 8 }}>
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).
</p>
<div className="seg-tabs" style={{ marginBottom: 16, flexWrap: "wrap" }}>
{atrasadas.length > 0 && (
<a href={linkFor(ATRASADAS)}>
<button className={win === ATRASADAS ? "on" : ""} type="button">
Atrasadas ({atrasadas.length})
</button>
</a>
)}
{futuros.map((m) => (
<a key={m} href={linkFor(m)}>
<button className={win === m ? "on" : ""} type="button">
{m}
</button>
</a>
))}
</div>
<div className="kpis">
<div className="kpi">
<span className="n">{p.length}</span>
<span className="l">Oportunidades en esta ventana</span>
</div>
<div className="kpi">
<span className="n">{money(est)}</span>
<span className="l">Valor estimado (MRR)</span>
</div>
<div className="kpi">
<span className="n">{money(fc)}</span>
<span className="l">Forecast ponderado</span>
</div>
<div className="kpi">
<span className={coverage != null && coverage < 100 ? "n pierde" : "n"}>
{coverage != null ? `${coverage}%` : "n/d"}
</span>
<span className="l">Coverage vs meta equipo ({money(metaGlobal)})</span>
</div>
<div className="kpi">
<span className="n pierde">{estanc.length}</span>
<span className="l">En riesgo (estancadas)</span>
</div>
</div>
<div className="callout warn">
<b>Forecast ponderado = Σ (MRR estimado × probabilidad del Plan de Cierre).</b> 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.
</div>
{Object.entries(groups).some(([, v]) => v.length > 0) && (
<div className="panel" style={{ marginTop: 18 }}>
<h3>Oportunidades para accionar esta semana</h3>
<p className="hint">Clasificado por palabras clave en "Estado de la Negociación" de Dynamics.</p>
</div>
)}
{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 (
<div className="panel" style={{ marginBottom: 14 }} key={key}>
<h3>
{BLOCKER_INFO[key].label} {list.length} oport. · {money(gEst)} est. · {money(gFc)} ponderado
</h3>
<p className="hint">{BLOCKER_INFO[key].accion}</p>
<div className="tw">
<div className="ts">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Oportunidad</th>
<th>Ejecutivo</th>
<th className="num">MRR</th>
<th>Nota de negociación</th>
</tr>
</thead>
<tbody>
{[...list].sort((a, b) => b.est - a.est).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>{o.owner}</td>
<td className="num">{money2(o.est)}</td>
<td style={{ color: "var(--tinta-suave)", fontSize: 12 }}>
{(o.estado || "").slice(0, 100)}
{(o.estado || "").length > 100 ? "…" : ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
})}
<div className="grid2" style={{ marginTop: 18 }}>
<div className="panel">
<h3>Forecast por Plan de Cierre</h3>
{agg(p, "plan", (o) => o.fc).map((r) => (
<div className="row static" key={r.k}>
<div className="bl">
<span className="nm">{r.k}</span>
</div>
<div className="bar-cell">
<div
className="bar-fill"
style={{ width: `${((r.n / Math.max(...agg(p, "plan").map((x) => x.n), 1)) * 100).toFixed(1)}%` }}
/>
</div>
<span className="v">{r.n}</span>
<span className="c">{money(r.v)}</span>
</div>
))}
</div>
<div className="panel">
<h3>Por Ejecutivo</h3>
{agg(p, "owner", (o) => o.est).map((r) => (
<div className="row dual" key={r.k}>
<div className="bl">
<span className="nm">{r.k}</span>
</div>
<div className="bar-cell">
<div
className="bar-fill"
style={{ width: `${((r.n / Math.max(...agg(p, "owner").map((x) => x.n), 1)) * 100).toFixed(1)}%` }}
/>
</div>
<span className="v">{r.n}</span>
<span className="c">{money(r.v)}</span>
</div>
))}
</div>
</div>
<div className="panel" style={{ paddingBottom: 6, marginTop: 18 }}>
<h3>Oportunidades con cierre estimado en esta ventana</h3>
<p className="hint">Forecast = MRR × peso del plan · clic abre Dynamics</p>
</div>
{p.length === 0 ? (
<p>No hay oportunidades en esta ventana.</p>
) : (
<div className="tw">
<div className="ts">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Oportunidad</th>
<th>Plan cierre</th>
<th>Ejecutivo</th>
<th className="num">MRR est.</th>
<th className="num">Forecast</th>
<th>Cierre est.</th>
</tr>
</thead>
<tbody>
{[...p].sort((a, b) => b.fc - a.fc).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>{(o.plan || "").replace(" - ", " ")}</td>
<td>{o.owner}</td>
<td className="num">{money2(o.est)}</td>
<td className="num">{money2(o.fc)}</td>
<td>{o.close || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

115
app/tendencia/page.js Normal file
View file

@ -0,0 +1,115 @@
import { auth } from "../lib/auth";
import { ownerFilter } from "../lib/authz";
import { getClosed, resolveScopeOwnerId } from "../lib/queries";
import LoginPrompt from "../components/LoginPrompt";
import ErrorNotice from "../components/ErrorNotice";
import { money, money2 } from "../lib/constants";
import { getMetaGlobal } from "../lib/metas";
export const dynamic = "force-dynamic";
const MESES_A_MOSTRAR = 6;
function lastMonths(n) {
const out = [];
const d = new Date();
for (let i = n - 1; i >= 0; i--) {
const dt = new Date(d.getFullYear(), d.getMonth() - i, 1);
out.push(`${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}`);
}
return out;
}
export default async function TendenciaPage({ searchParams }) {
const session = await auth();
if (!session?.email) return <LoginPrompt />;
const { owner } = await searchParams;
const scope = ownerFilter(session, owner);
const meses = lastMonths(MESES_A_MOSTRAR);
const metaGlobal = getMetaGlobal();
let porMes;
try {
const { ownerId } = await resolveScopeOwnerId(scope, session.accessToken);
porMes = await Promise.all(
meses.map(async (m) => {
const [won, lost] = await Promise.all([
getClosed(session.accessToken, scope, 1, m, ownerId),
getClosed(session.accessToken, scope, 2, m, ownerId),
]);
const sumW = won.reduce((a, o) => a + (o.actual || 0), 0);
return {
mes: m,
won: won.length,
lost: lost.length,
sumW,
arpu: won.length ? sumW / won.length : 0,
conv: won.length + lost.length ? Math.round((won.length / (won.length + lost.length)) * 100) : 0,
cumpl: metaGlobal ? Math.round((sumW / metaGlobal) * 100) : null,
};
})
);
} catch (error) {
return <ErrorNotice error={error} />;
}
const maxSumW = Math.max(...porMes.map((m) => m.sumW), 1);
return (
<div style={{ padding: 24 }}>
<h2>Tendencia</h2>
<p className="hint" style={{ marginBottom: 16 }}>
Últimos {MESES_A_MOSTRAR} meses, calculado en vivo desde Dynamics. No incluye el análisis narrativo del
tablero anterior (era específico de un semestre puntual) solo las cifras.
</p>
<div className="panel">
<h3>Ganadas ($) por mes</h3>
{porMes.map((m) => (
<div className="row static" key={m.mes}>
<div className="bl">
<span className="nm">{m.mes}</span>
</div>
<div className="bar-cell">
<div className="bar-fill" style={{ width: `${((m.sumW / maxSumW) * 100).toFixed(1)}%` }} />
</div>
<span className="v">{m.won}</span>
<span className="c">{money(m.sumW)}</span>
</div>
))}
</div>
<div className="tw" style={{ marginTop: 18 }}>
<div className="ts">
<table>
<thead>
<tr>
<th>Mes</th>
<th className="num">Ganadas #</th>
<th className="num">Ganadas $</th>
<th className="num">ARPU</th>
<th className="num">Perdidas #</th>
<th className="num">Conversión</th>
<th className="num">Cumpl. (meta {money(metaGlobal)})</th>
</tr>
</thead>
<tbody>
{porMes.map((m) => (
<tr key={m.mes}>
<td>{m.mes}</td>
<td className="num">{m.won}</td>
<td className="num">{money2(m.sumW)}</td>
<td className="num">{money2(m.arpu)}</td>
<td className="num">{m.lost}</td>
<td className="num">{m.conv}%</td>
<td className="num">{m.cumpl != null ? `${m.cumpl}%` : "n/d"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

124
app/tuberia/page.js Normal file
View file

@ -0,0 +1,124 @@
import { auth } from "../lib/auth";
import { ownerFilter } from "../lib/authz";
import { getPipeline, getSystemUsers } from "../lib/queries";
import LoginPrompt from "../components/LoginPrompt";
import OwnerControl from "../components/OwnerControl";
import ErrorNotice from "../components/ErrorNotice";
import { DYN, ACT_COLOR, money, money2 } from "../lib/constants";
import { EJECUTIVOS_VENTA } from "../lib/derive";
export const dynamic = "force-dynamic";
export default async function TuberiaPage({ searchParams }) {
const session = await auth();
if (!session?.email) return <LoginPrompt />;
const { owner } = await searchParams;
const scope = ownerFilter(session, owner);
let rows, allUsers;
try {
[rows, allUsers] = await Promise.all([
getPipeline(session.accessToken, scope),
session.isAdmin ? getSystemUsers(session.accessToken) : Promise.resolve([]),
]);
} catch (error) {
return <ErrorNotice error={error} />;
}
const owners = allUsers.filter((u) => EJECUTIVOS_VENTA.includes(u.name));
const sorted = [...rows].sort((a, b) => b.est - a.est);
const totalEst = rows.reduce((a, r) => a + r.est, 0);
const totalFc = rows.reduce((a, r) => a + r.fc, 0);
const estancadas = rows.filter((r) => r.estanc).length;
return (
<div style={{ padding: 24 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
<h2>Tubería Activa</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">{rows.length}</span>
<span className="l">Oportunidades activas</span>
</div>
<div className="kpi">
<span className="n">{money(totalEst)}</span>
<span className="l">Estimado total</span>
</div>
<div className="kpi">
<span className="n">{money(totalFc)}</span>
<span className="l">Forecast ponderado</span>
</div>
<div className="kpi">
<span className="n pierde">{estancadas}</span>
<span className="l">Estancadas</span>
</div>
</div>
{sorted.length === 0 ? (
<p>No hay oportunidades activas para este filtro.</p>
) : (
<div className="tw">
<div className="ts">
<table>
<thead>
<tr>
<th>Cliente</th>
<th>Oportunidad</th>
<th>Seg.</th>
<th>Plan cierre</th>
<th>Línea de Negocio</th>
<th>Fase actual</th>
<th className="num">Días en fase</th>
<th>Actividad</th>
{!scope.locked && <th>Ejecutivo</th>}
<th className="num">MRR est.</th>
<th className="num">Forecast</th>
<th>Cierre est.</th>
</tr>
</thead>
<tbody>
{sorted.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.plan || "").replace(" - ", " ")}</td>
<td>{o.linea || "—"}</td>
<td>{o.faseActual || "—"}</td>
<td className="num">{o.diasEnFase == null ? "—" : o.diasEnFase}</td>
<td>
<span style={{ color: ACT_COLOR[o.actividad] || "#9db4c9", fontWeight: 700 }}>
{o.actividad}
</span>
</td>
{!scope.locked && <td>{o.owner}</td>}
<td className="num">{money2(o.est)}</td>
<td className="num">{money2(o.fc)}</td>
<td>{o.close || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

7
next.config.js Normal file
View file

@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["better-sqlite3"],
images: { unoptimized: true },
};
module.exports = nextConfig;

1137
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "informe-comercial",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"better-sqlite3": "^13.0.3",
"next": "^16.3.5",
"next-auth": "^5.0.0-beta.32",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"allowScripts": {
"better-sqlite3@11.8.1": true
}
}

View file

@ -0,0 +1,12 @@
@font-face{font-family:'Ubuntu Sans';font-weight:400;font-style:normal;src:url(../fonts/UbuntuSans-Regular.ttf) format('truetype');}
@font-face{font-family:'Ubuntu Sans';font-weight:700;font-style:normal;src:url(../fonts/UbuntuSans-Bold.ttf) format('truetype');}
:root{
--marca:#1E71B8; --ancla:#01205B; --profundo:#134766; --palido:#D1EBF7; --gris:#F2F3F4;
--tinta:#0e1c2b; --tinta-suave:#5a6b7a; --linea:#dfe4ea; --panel:#ffffff;
--gana:#1E71B8; --gana-soft:#dbeaf6; --pierde:#B23A2E; --pierde-soft:#f6e2de; --alerta:#C98A1B; --alerta-soft:#f7edd6;
--sans:'Ubuntu Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
--mono:'SF Mono','Cascadia Code',Consolas,monospace;
}
*{box-sizing:border-box;}
body{margin:0;background:var(--gris);color:var(--tinta);font-family:var(--sans);line-height:1.45;-webkit-font-smoothing:antialiased;}
select{font-family:var(--sans);font-size:13px;padding:6px 10px;border:1px solid var(--linea);border-radius:6px;background:#fff;color:var(--tinta);cursor:pointer;}

View file

@ -0,0 +1,86 @@
.ctrl{display:flex;align-items:center;gap:8px;}
.ctrl label{font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--tinta-suave);font-weight:700;}
/* owner multi-select */
.owner-ctrl{position:relative;}
.owner-btn{font-family:var(--sans);font-size:13px;padding:6px 10px;border:1px solid var(--linea);border-radius:6px;background:#fff;color:var(--tinta);cursor:pointer;min-width:150px;text-align:left;display:inline-flex;justify-content:space-between;align-items:center;gap:10px;}
.owner-btn:after{content:'▾';font-size:10px;color:var(--tinta-suave);}
.owner-btn:disabled{cursor:default;opacity:.85;}
.owner-btn:disabled:after{content:'';}
.owner-panel{display:none;position:absolute;top:calc(100% + 6px);right:0;background:#fff;border:1px solid var(--linea);border-radius:8px;box-shadow:0 8px 24px rgba(1,32,91,.18);padding:8px;min-width:250px;max-height:340px;overflow-y:auto;z-index:15;}
.owner-panel.open{display:block;}
.owner-panel .oa{display:flex;gap:10px;flex-wrap:wrap;padding:2px 6px 8px;border-bottom:1px solid var(--linea);margin-bottom:4px;}
.owner-panel .oa a{font-size:11px;color:var(--marca);cursor:pointer;text-decoration:none;font-weight:700;}
.owner-panel .oa a:hover{text-decoration:underline;}
.owner-panel .owner-row{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:5px;font-size:12.5px;text-transform:none;letter-spacing:normal;font-weight:400;color:var(--tinta);cursor:pointer;}
.owner-row:hover{background:var(--gana-soft);}
.owner-row input{cursor:pointer;}
/* login button (auth.js) */
.login-btn{font-family:var(--sans);font-size:13px;padding:6px 14px;border:1px solid var(--marca);border-radius:6px;background:var(--marca);color:#fff;cursor:pointer;font-weight:700;text-decoration:none;display:inline-block;}
.login-btn:hover{opacity:.9;}
/* filter chips */
.chips{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:18px;min-height:26px;align-items:center;}
.chips .lbl{font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--tinta-suave);}
.chip{font-size:12px;background:var(--gana-soft);color:var(--profundo);border:1px solid var(--marca);border-radius:13px;padding:4px 11px;cursor:pointer;}
.chip.clear{background:none;border-color:var(--tinta-suave);color:var(--tinta-suave);}
.chip-empty{font-size:12.5px;color:var(--tinta-suave);font-style:italic;}
/* kpi */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:22px;}
.kpi{background:var(--panel);border:1px solid var(--linea);border-radius:8px;padding:16px 18px;display:flex;flex-direction:column;gap:5px;}
.kpi .n{font-size:27px;font-weight:700;color:var(--ancla);line-height:1;font-variant-numeric:tabular-nums;}
.kpi .n.pierde{color:var(--pierde);}
.kpi .l{font-size:11.5px;color:var(--tinta-suave);}
.kpi .d{font-size:11px;font-variant-numeric:tabular-nums;}
.kpi .d.up{color:var(--gana);} .kpi .d.down{color:var(--pierde);}
/* panels grid */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:18px;}
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:18px;}
.panel{background:var(--panel);border:1px solid var(--linea);border-radius:8px;padding:16px 18px;margin-bottom:18px;}
.panel h3{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--profundo);margin:0 0 4px;}
.panel .hint{font-size:11px;color:var(--tinta-suave);margin:0 0 12px;}
/* bar rows */
.row{display:grid;grid-template-columns:1fr 90px 34px;align-items:center;gap:9px;padding:5px 4px;border-radius:5px;cursor:pointer;transition:background .1s,opacity .12s;}
.row:hover{background:var(--gana-soft);}
.row.active{background:var(--gana-soft);}
.row.dim{opacity:.34;}
.row.static{cursor:default;} .row.static:hover{background:none;}
.row .bl{display:flex;align-items:center;gap:8px;min-width:0;}
.row .nm{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.row .track{position:absolute;inset:0;background:var(--gana-soft);border-radius:3px;}
.row .barwrap{position:relative;height:20px;border-radius:3px;background:#eef2f5;overflow:hidden;grid-column:1;display:none;}
.bar-cell{position:relative;height:9px;background:#edf1f5;border-radius:3px;overflow:hidden;flex:1;}
.bar-fill{position:absolute;left:0;top:0;height:100%;border-radius:3px;background:var(--marca);}
.bar-fill.pierde{background:var(--pierde);}
.bar-fill.alerta{background:var(--alerta);}
.row .v{font-family:var(--mono);font-size:11.5px;text-align:right;font-variant-numeric:tabular-nums;}
.row .c{font-family:var(--mono);font-size:10.5px;text-align:right;color:var(--tinta-suave);}
.row.dual{grid-template-columns:1fr 78px 40px 76px;}
/* callout */
.callout{border-radius:0 8px 8px 0;padding:14px 18px;font-size:13.5px;margin-bottom:14px;}
.callout.info{background:var(--palido);border-left:4px solid var(--marca);}
.callout.warn{background:var(--alerta-soft);border-left:4px solid var(--alerta);}
.callout.bad{background:var(--pierde-soft);border-left:4px solid var(--pierde);}
.callout b{color:var(--ancla);}
.callout.bad b{color:var(--pierde);}
/* table */
.tw{overflow-x:auto;border:1px solid var(--linea);border-radius:8px;background:var(--panel);}
.ts{max-height:460px;overflow-y:auto;}
table{width:100%;border-collapse:collapse;font-size:12.5px;min-width:760px;}
thead th{position:sticky;top:0;background:var(--gris);text-align:left;font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--tinta-suave);font-weight:700;padding:9px 12px;border-bottom:1px solid var(--linea);white-space:nowrap;}
tbody td{padding:7px 12px;border-bottom:1px solid var(--linea);white-space:nowrap;}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums;}
tbody td.wrap{white-space:normal;min-width:220px;max-width:340px;font-size:11.5px;color:var(--tinta-suave);}
tbody tr:hover{background:var(--gana-soft);}
tbody tr:last-child td{border-bottom:none;}
a.opp{color:var(--marca);text-decoration:none;font-weight:700;}
a.opp:hover{text-decoration:underline;}
.pill{display:inline-block;font-size:10.5px;padding:2px 8px;border-radius:10px;font-weight:700;}
.pill.nuevo{background:var(--gana-soft);color:var(--profundo);}
.pill.cartera{background:var(--alerta-soft);color:#8a6414;}
.seg-tabs{display:inline-flex;border:1px solid var(--linea);border-radius:7px;overflow:hidden;}
.seg-tabs button{font-family:var(--sans);font-size:12.5px;padding:6px 14px;border:0;background:#fff;color:var(--tinta-suave);cursor:pointer;}
.seg-tabs button.on{background:var(--marca);color:#fff;font-weight:700;}
.soon-box{text-align:center;padding:80px 20px;color:var(--tinta-suave);}
.soon-box .big{font-size:44px;margin-bottom:12px;}
.soon-box h2{color:var(--ancla);font-size:22px;margin:0 0 8px;}
.rep-btn{font-family:var(--sans);font-size:13px;padding:8px 16px;border:1px solid var(--marca);border-radius:6px;background:#fff;color:var(--marca);cursor:pointer;font-weight:700;}
.rep-btn:hover{background:var(--gana-soft);}

View file

@ -0,0 +1,39 @@
.app{display:flex;min-height:100vh;}
/* SIDEBAR */
.side{width:230px;background:var(--ancla);color:#fff;flex-shrink:0;display:flex;flex-direction:column;position:sticky;top:0;height:100vh;}
.side-logo{padding:22px 20px 16px;border-bottom:1px solid rgba(255,255,255,.12);}
.side-logo img{height:30px;width:auto;display:block;}
.side-logo .sub{font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--palido);margin-top:8px;opacity:.85;}
.nav{padding:10px 0;overflow-y:auto;flex:1;}
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 20px;font-size:13.5px;color:#cfe0ef;cursor:pointer;border-left:3px solid transparent;user-select:none;}
.nav-item:hover{background:rgba(255,255,255,.06);color:#fff;}
.nav-item.active{background:rgba(30,113,184,.28);border-left-color:var(--marca);color:#fff;font-weight:700;}
.nav-item.soon{color:#6f8399;cursor:not-allowed;}
.nav-item.soon:hover{background:none;}
.nav-item .ico{width:16px;text-align:center;font-size:14px;}
.nav-item .tag{margin-left:auto;font-size:8.5px;letter-spacing:.05em;background:rgba(255,255,255,.12);color:#9db4c9;padding:2px 6px;border-radius:8px;text-transform:uppercase;}
.side-foot{padding:12px 20px;border-top:1px solid rgba(255,255,255,.12);font-family:var(--mono);font-size:10px;color:#7f95aa;}
/* MAIN */
.main{flex:1;min-width:0;display:flex;flex-direction:column;}
.topbar{background:var(--panel);border-bottom:1px solid var(--linea);padding:14px 28px;display:flex;align-items:center;gap:20px;flex-wrap:wrap;position:sticky;top:0;z-index:10;}
.topbar h1{font-size:19px;font-weight:700;margin:0;color:var(--ancla);flex:1;min-width:180px;}
.content{padding:26px 28px 80px;max-width:1180px;}
.page{display:none;}
.page.active{display:block;}
.hamburger{display:none;background:none;border:1px solid var(--linea);border-radius:6px;padding:6px 10px;font-size:18px;cursor:pointer;color:var(--ancla);line-height:1;}
.backdrop{display:none;position:fixed;inset:0;background:rgba(1,32,91,.35);z-index:19;}
@media(max-width:820px){
.side{position:fixed;top:0;left:0;z-index:20;transform:translateX(-100%);transition:transform .2s ease;}
.side.open{transform:translateX(0);}
.backdrop.open{display:block;}
.hamburger{display:inline-block;}
.grid2,.grid3{grid-template-columns:1fr;}
.content{padding:20px 16px 80px;}
}
@media (prefers-reduced-motion: reduce){.side{transition:none;}}
@media print{
body.rep-print .side,body.rep-print .topbar,body.rep-print .backdrop,body.rep-print .content,body.rep-print #mapModalBd,body.rep-print #mapModal{display:none !important;}
body.rep-print #repModalBd{display:none !important;}
body.rep-print #repModal{position:static !important;inset:auto !important;box-shadow:none !important;border-radius:0 !important;padding:0 !important;max-height:none !important;overflow:visible !important;}
body.rep-print .rep-actions{display:none !important;}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB