initial deploy: informe-comercial-alfa
This commit is contained in:
commit
ad0585ed85
36 changed files with 3258 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal 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
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.next
|
||||
data
|
||||
__nm_old
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
101
app/admin/metas/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
2
app/api/auth/[...nextauth]/route.js
Normal file
2
app/api/auth/[...nextauth]/route.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { handlers } from "../../../lib/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
84
app/components/AppShell.js
Normal file
84
app/components/AppShell.js
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/components/ErrorNotice.js
Normal file
17
app/components/ErrorNotice.js
Normal 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>
|
||||
);
|
||||
}
|
||||
8
app/components/LoginPrompt.js
Normal file
8
app/components/LoginPrompt.js
Normal 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>
|
||||
);
|
||||
}
|
||||
41
app/components/OwnerControl.js
Normal file
41
app/components/OwnerControl.js
Normal 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
139
app/detalle/page.js
Normal 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
34
app/diagnostico/page.js
Normal 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
196
app/ganadas/page.js
Normal 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 <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
10
app/globals.css
Normal 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
54
app/layout.js
Normal 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
72
app/lib/auth.js
Normal 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
9
app/lib/authz.js
Normal 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
14
app/lib/constants.js
Normal 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
38
app/lib/dataverse.js
Normal 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
35
app/lib/db.js
Normal 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
75
app/lib/derive.js
Normal 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
34
app/lib/metas.js
Normal 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
182
app/lib/queries.js
Normal 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
99
app/page.js
Normal 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
161
app/perdemos/page.js
Normal 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
282
app/prevision/page.js
Normal 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
115
app/tendencia/page.js
Normal 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
124
app/tuberia/page.js
Normal 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
7
next.config.js
Normal 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
1137
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal 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
|
||||
}
|
||||
}
|
||||
12
public/assets/css/base.css
Normal file
12
public/assets/css/base.css
Normal 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;}
|
||||
86
public/assets/css/components.css
Normal file
86
public/assets/css/components.css
Normal 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);}
|
||||
39
public/assets/css/layout.css
Normal file
39
public/assets/css/layout.css
Normal 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;}
|
||||
}
|
||||
BIN
public/assets/fonts/UbuntuSans-Bold.ttf
Normal file
BIN
public/assets/fonts/UbuntuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
public/assets/fonts/UbuntuSans-Regular.ttf
Normal file
BIN
public/assets/fonts/UbuntuSans-Regular.ttf
Normal file
Binary file not shown.
BIN
public/assets/img/logo-alfa-blanco.png
Normal file
BIN
public/assets/img/logo-alfa-blanco.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Loading…
Reference in a new issue