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