commit ad0585ed85e821138b319f6addbbc5712291d8f7 Author: MarcoMontenegro Date: Mon Sep 21 17:11:53 2026 -0600 initial deploy: informe-comercial-alfa diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..70df0dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +data +__nm_old +.nm_trash* +*.log +npm-debug.log* +.env +.env.local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28c3b14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.next +data +__nm_old +*.log +.DS_Store +.env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6934968 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/admin/metas/page.js b/app/admin/metas/page.js new file mode 100644 index 0000000..6ec3877 --- /dev/null +++ b/app/admin/metas/page.js @@ -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 ; + if (!session.isAdmin) { + return ( +
+

Metas

+

Esta página es solo para administradores.

+
+ ); + } + + 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 ( +
+

Metas mensuales

+

+ Solo administradores. Se guardan en SQLite (sobreviven a cada publicación, ver Etapa 4 en migration.md). +

+ +
+

Meta global del equipo

+
+ + +
+
+ +
+

Meta por ejecutiva

+ {vendedoras.map((v) => ( +
+ {v.nombre} + {v.email ? ( +
+ + + +
+ ) : ( + No encontrada en systemusers + )} +
+ ))} +
+
+ ); +} diff --git a/app/api/auth/[...nextauth]/route.js b/app/api/auth/[...nextauth]/route.js new file mode 100644 index 0000000..e65717d --- /dev/null +++ b/app/api/auth/[...nextauth]/route.js @@ -0,0 +1,2 @@ +import { handlers } from "../../../lib/auth"; +export const { GET, POST } = handlers; diff --git a/app/components/AppShell.js b/app/components/AppShell.js new file mode 100644 index 0000000..a29cbc2 --- /dev/null +++ b/app/components/AppShell.js @@ -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 ( +
+ + +
setOpen(false)} /> + +
+
+ +
+ {session?.email ? ( + <> + {session.isAdmin && ( + + ⚙ Metas + + )} + + {session.email} {session.isAdmin ? "(admin)" : ""} + +
+ +
+ + ) : ( +
+ +
+ )} +
+
+
{children}
+
+
+ ); +} diff --git a/app/components/ErrorNotice.js b/app/components/ErrorNotice.js new file mode 100644 index 0000000..47db537 --- /dev/null +++ b/app/components/ErrorNotice.js @@ -0,0 +1,17 @@ +export default function ErrorNotice({ error }) { + const isExpired = error?.message === "TOKEN_EXPIRED" || error?.message?.includes("RefreshFailed"); + return ( +
+

No se pudo cargar la información

+ {isExpired ? ( +

Tu sesión con Dynamics expiró. Cerrá sesión y volvé a iniciarla.

+ ) : ( +

+ Dynamics no respondió correctamente (posiblemente por límite de consultas). Recargá la página en unos + segundos. +

+ )} +

{error?.message}

+
+ ); +} diff --git a/app/components/LoginPrompt.js b/app/components/LoginPrompt.js new file mode 100644 index 0000000..21212b6 --- /dev/null +++ b/app/components/LoginPrompt.js @@ -0,0 +1,8 @@ +export default function LoginPrompt() { + return ( +
+

Informe Comercial ALFA+

+

Iniciá sesión con tu cuenta de Alfanumeric para ver tu información.

+
+ ); +} diff --git a/app/components/OwnerControl.js b/app/components/OwnerControl.js new file mode 100644 index 0000000..07f5e66 --- /dev/null +++ b/app/components/OwnerControl.js @@ -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 ( +
+ + +
+ ); + } + + 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 ( +
+ + +
+ ); +} diff --git a/app/detalle/page.js b/app/detalle/page.js new file mode 100644 index 0000000..01a8fa7 --- /dev/null +++ b/app/detalle/page.js @@ -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 ; + + 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 ; + } + 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 ( +
+

Detalle

+ +
+ {SETS.map((s) => ( + + + + ))} +
+ +

+ {rows.length} oportunidades{detSet !== "pipe" ? ` (${mes})` : ""} · clic en el nombre abre la oportunidad en + Dynamics 365. +

+ + {sorted.length === 0 ? ( +

No hay oportunidades para este filtro.

+ ) : ( +
+
+ + + + + + + {!scope.locked && } + {detSet === "pipe" ? ( + <> + + + + + + + ) : ( + <> + + + + + )} + + + + {sorted.map((o) => ( + + + + + {!scope.locked && } + {detSet === "pipe" ? ( + <> + + + + + + + ) : ( + <> + + + + + )} + + ))} + +
ClienteOportunidadSeg.EjecutivoPlan cierreFase actualActividadMRR est.ForecastTipoMRRCierre
{o.cliente || "—"} + + {o.name} + + + + {o.segmento === "Cartera Activa" ? "CA" : "CN"} + + {o.owner}{(o.plan || "").replace(" - ", " ")}{o.faseActual || "—"} + + ● {o.actividad} + + {money2(o.est)}{money2(o.fc)}{o.tipo}{money2(o.actual)}{o.actualClose || "—"}
+
+
+ )} +
+ ); +} diff --git a/app/diagnostico/page.js b/app/diagnostico/page.js new file mode 100644 index 0000000..64d22c7 --- /dev/null +++ b/app/diagnostico/page.js @@ -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 ; + + const check = session.accessToken ? await checkDataverse(session.accessToken) : { ok: false, error: "sin accessToken" }; + + return ( +
+

Diagnóstico

+
    +
  • Correo: {session.email}
  • +
  • Rol: {session.isAdmin ? "Administrador" : "Vendedor"}
  • +
  • Token de Dataverse: {check.ok ? "✅ funciona" : `❌ ${check.error}`}
  • + {check.ok &&
  • Prueba de lectura: {check.sample}
  • } + {session.error &&
  • Error de sesión: {session.error}
  • } +
+
+ ); +} diff --git a/app/ganadas/page.js b/app/ganadas/page.js new file mode 100644 index 0000000..7f74d8b --- /dev/null +++ b/app/ganadas/page.js @@ -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 ( +
+

{title}

+ {hint &&

{hint}

} + {rows.map((r) => ( +
+
+ {r.k} +
+
+
+
+ {r.n} + {money(r.v)} +
+ ))} +
+ ); +} + +export default async function GanadasPage({ searchParams }) { + const session = await auth(); + if (!session?.email) return ; + + 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 ; + } + 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 ( +
+
+

Ventas Ganadas — {mes}

+ +
+ +
+
+ {money2(sumW)} + Suma ingresos reales +
+
+ {won.length} + Oportunidades ganadas +
+
+ {money2(arpu)} + ARPU logrado +
+
+ {conv}% + % Conversión +
+
+ + {cumplGlobal != null ? `${cumplGlobal}%` : "n/d"} + + Cumplimiento equipo +
+
+ +
+

Cumplimiento de meta por ejecutivo

+

Ingresos reales ÷ meta mensual · verde ≥70% · ámbar 50-69% · rojo <50%

+ {porEjecutivo.map((r) => ( +
+
+ {r.nombre} +
+
+
= 70 ? "" : r.pct >= 50 ? "alerta" : "pierde"}`} + style={{ width: `${Math.min(100, r.pct ?? 0)}%` }} + /> +
+ {r.pct == null ? "n/d" : `${r.pct}%`} + + {money(r.logrado)} + {r.meta ? `/${money(r.meta)}` : ""} + +
+ ))} +
+ +
+ o.actual)} /> + o.actual)} /> + o.actual)} /> + o.actual)} /> +
+ +
+

Detalle de oportunidades ganadas

+

Clic en el nombre abre la oportunidad en Dynamics 365

+
+ {won.length === 0 ? ( +

No hay oportunidades ganadas en este período para este filtro.

+ ) : ( +
+
+ + + + + + + {!scope.locked && } + + + + + + + + + {[...won].sort((a, b) => b.actual - a.actual).map((o) => ( + + + + + {!scope.locked && } + + + + + + + ))} + +
ClienteOportunidadSeg.EjecutivoTipoLínea de NegocioOrigenMRRCierre
{o.cliente || "—"} + + {o.name} + + + + {o.segmento === "Cartera Activa" ? "CA" : "CN"} + + {o.owner}{o.tipo}{o.linea}{o.origen}{money2(o.actual)}{o.actualClose || "—"}
+
+
+ )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..dbb3cf7 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.js b/app/layout.js new file mode 100644 index 0000000..b501fe6 --- /dev/null +++ b/app/layout.js @@ -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 ( + + + + + + + + + {children} + + + + ); +} diff --git a/app/lib/auth.js b/app/lib/auth.js new file mode 100644 index 0000000..0bbe40e --- /dev/null +++ b/app/lib/auth.js @@ -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://.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" }, +}); diff --git a/app/lib/authz.js b/app/lib/authz.js new file mode 100644 index 0000000..c75748b --- /dev/null +++ b/app/lib/authz.js @@ -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 }; +} diff --git a/app/lib/constants.js b/app/lib/constants.js new file mode 100644 index 0000000..06737bd --- /dev/null +++ b/app/lib/constants.js @@ -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 }); diff --git a/app/lib/dataverse.js b/app/lib/dataverse.js new file mode 100644 index 0000000..1baef05 --- /dev/null +++ b/app/lib/dataverse.js @@ -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, "''"); +} + diff --git a/app/lib/db.js b/app/lib/db.js new file mode 100644 index 0000000..1045973 --- /dev/null +++ b/app/lib/db.js @@ -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; diff --git a/app/lib/derive.js b/app/lib/derive.js new file mode 100644 index 0000000..d06e335 --- /dev/null +++ b/app/lib/derive.js @@ -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, + }; +} diff --git a/app/lib/metas.js b/app/lib/metas.js new file mode 100644 index 0000000..6aafde5 --- /dev/null +++ b/app/lib/metas.js @@ -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)); +} diff --git a/app/lib/queries.js b/app/lib/queries.js new file mode 100644 index 0000000..2ba1195 --- /dev/null +++ b/app/lib/queries.js @@ -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) => `${id}`).join(""); + const fetchXml = + `` + + `` + + `` + + `${conditions}` + + ``; + 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) => `${id}`).join(""); + const fetchXml = + `` + + `` + + `` + + `` + + `${conditions}` + + ``; + 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); +} diff --git a/app/page.js b/app/page.js new file mode 100644 index 0000000..a9598c8 --- /dev/null +++ b/app/page.js @@ -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 ; + + 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 ; + } + 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 ( +
+
+

Resumen — {mes}

+ +
+ +
+
+ {money2(sumW)} + Ingresos reales (ganadas) +
+
+ {won.length} + Oportunidades ganadas +
+
+ {money2(arpu)} + ARPU logrado +
+
+ {lost.length} + Oportunidades perdidas +
+
+ {conv}% + % Conversión (ganadas/total) +
+
+ + {cumpl != null ? cumpl + "%" : "n/d"} + + + Cumplimiento de meta{metaDen ? ` (${money2(metaDen)})` : " (sin meta asignada)"} + +
+
+ +

+ Cumplimiento de meta cuenta solo Venta - Cliente Nuevo, Up-Selling, Cross-Selling y Plan de Cuenta (se + excluyen renovaciones y SAC). +

+
+ ); +} diff --git a/app/perdemos/page.js b/app/perdemos/page.js new file mode 100644 index 0000000..f87a9cb --- /dev/null +++ b/app/perdemos/page.js @@ -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 ( +
+

{title}

+ {hint &&

{hint}

} + {rows.map((r) => ( +
+
+ {r.k} +
+
+
+
+ {r.n} + {money(r.v)} +
+ ))} +
+ ); +} + +export default async function PerdemosPage({ searchParams }) { + const session = await auth(); + if (!session?.email) return ; + + 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 ; + } + 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 ( +
+
+

Por qué Perdemos — {mes}

+ +
+ +
+
+ {lost.length} + Oportunidades perdidas +
+
+ {money(mrrPerdido)} + MRR estimado perdido +
+
+ {clientesNuevos} + Clientes Nuevos +
+
+ {carteraActiva} + Cartera Activa +
+
+ +

+ 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). +

+ +
+ o.est)} + /> + o.competidor && o.competidor !== "Ninguno"), "competidor", (o) => o.est)} /> + o.est)} /> + o.est)} /> +
+ +
+

Detalle de oportunidades perdidas

+

Clic en el nombre abre la oportunidad en Dynamics 365

+
+ {lost.length === 0 ? ( +

No hay oportunidades perdidas en este período para este filtro.

+ ) : ( +
+
+ + + + + + + + + + {!scope.locked && } + + + + + {[...lost].sort((a, b) => (b.est || 0) - (a.est || 0)).map((o) => ( + + + + + + + + {!scope.locked && } + + + ))} + +
ClienteOportunidadSeg.Motivo de cierreLínea de NegocioCompetidorEjecutivoMRR est.
{o.cliente || "—"} + + {o.name} + + + + {o.segmento === "Cartera Activa" ? "CA" : "CN"} + + {o.motivo}{o.linea || "—"}{o.competidor || "—"}{o.owner}{o.est ? money2(o.est) : "—"}
+
+
+ )} +
+ ); +} diff --git a/app/prevision/page.js b/app/prevision/page.js new file mode 100644 index 0000000..5bd4703 --- /dev/null +++ b/app/prevision/page.js @@ -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 ; + + 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 ; + } + 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 ( +
+
+

Previsión de Ventas

+ +
+ +

+ 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). +

+ +
+ {atrasadas.length > 0 && ( + + + + )} + {futuros.map((m) => ( + + + + ))} +
+ +
+
+ {p.length} + Oportunidades en esta ventana +
+
+ {money(est)} + Valor estimado (MRR) +
+
+ {money(fc)} + Forecast ponderado +
+
+ + {coverage != null ? `${coverage}%` : "n/d"} + + Coverage vs meta equipo ({money(metaGlobal)}) +
+
+ {estanc.length} + En riesgo (estancadas) +
+
+ +
+ Forecast ponderado = Σ (MRR estimado × probabilidad del Plan de Cierre). 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. +
+ + {Object.entries(groups).some(([, v]) => v.length > 0) && ( +
+

Oportunidades para accionar esta semana

+

Clasificado por palabras clave en "Estado de la Negociación" de Dynamics.

+
+ )} + {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 ( +
+

+ {BLOCKER_INFO[key].label} — {list.length} oport. · {money(gEst)} est. · {money(gFc)} ponderado +

+

{BLOCKER_INFO[key].accion}

+
+
+ + + + + + + + + + + + {[...list].sort((a, b) => b.est - a.est).map((o) => ( + + + + + + + + ))} + +
ClienteOportunidadEjecutivoMRRNota de negociación
{o.cliente || "—"} + + {o.name} + + {o.owner}{money2(o.est)} + {(o.estado || "").slice(0, 100)} + {(o.estado || "").length > 100 ? "…" : ""} +
+
+
+
+ ); + })} + +
+
+

Forecast por Plan de Cierre

+ {agg(p, "plan", (o) => o.fc).map((r) => ( +
+
+ {r.k} +
+
+
x.n), 1)) * 100).toFixed(1)}%` }} + /> +
+ {r.n} + {money(r.v)} +
+ ))} +
+
+

Por Ejecutivo

+ {agg(p, "owner", (o) => o.est).map((r) => ( +
+
+ {r.k} +
+
+
x.n), 1)) * 100).toFixed(1)}%` }} + /> +
+ {r.n} + {money(r.v)} +
+ ))} +
+
+ +
+

Oportunidades con cierre estimado en esta ventana

+

Forecast = MRR × peso del plan · clic abre Dynamics

+
+ {p.length === 0 ? ( +

No hay oportunidades en esta ventana.

+ ) : ( +
+
+ + + + + + + + + + + + + + {[...p].sort((a, b) => b.fc - a.fc).map((o) => ( + + + + + + + + + + ))} + +
ClienteOportunidadPlan cierreEjecutivoMRR est.ForecastCierre est.
{o.cliente || "—"} + + {o.name} + + {(o.plan || "").replace(" - ", " ")}{o.owner}{money2(o.est)}{money2(o.fc)}{o.close || "—"}
+
+
+ )} +
+ ); +} diff --git a/app/tendencia/page.js b/app/tendencia/page.js new file mode 100644 index 0000000..ae85f3e --- /dev/null +++ b/app/tendencia/page.js @@ -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 ; + + 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 ; + } + + const maxSumW = Math.max(...porMes.map((m) => m.sumW), 1); + + return ( +
+

Tendencia

+

+ Ú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. +

+ +
+

Ganadas ($) por mes

+ {porMes.map((m) => ( +
+
+ {m.mes} +
+
+
+
+ {m.won} + {money(m.sumW)} +
+ ))} +
+ +
+
+ + + + + + + + + + + + + + {porMes.map((m) => ( + + + + + + + + + + ))} + +
MesGanadas #Ganadas $ARPUPerdidas #ConversiónCumpl. (meta {money(metaGlobal)})
{m.mes}{m.won}{money2(m.sumW)}{money2(m.arpu)}{m.lost}{m.conv}%{m.cumpl != null ? `${m.cumpl}%` : "n/d"}
+
+
+
+ ); +} diff --git a/app/tuberia/page.js b/app/tuberia/page.js new file mode 100644 index 0000000..a226023 --- /dev/null +++ b/app/tuberia/page.js @@ -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 ; + + 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 ; + } + 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 ( +
+
+

Tubería Activa

+ +
+ +
+
+ {rows.length} + Oportunidades activas +
+
+ {money(totalEst)} + Estimado total +
+
+ {money(totalFc)} + Forecast ponderado +
+
+ {estancadas} + Estancadas +
+
+ + {sorted.length === 0 ? ( +

No hay oportunidades activas para este filtro.

+ ) : ( +
+
+ + + + + + + + + + + + {!scope.locked && } + + + + + + + {sorted.map((o) => ( + + + + + + + + + + {!scope.locked && } + + + + + ))} + +
ClienteOportunidadSeg.Plan cierreLínea de NegocioFase actualDías en faseActividadEjecutivoMRR est.ForecastCierre est.
{o.cliente || "—"} + + {o.name} + + + + {o.segmento === "Cartera Activa" ? "CA" : "CN"} + + {(o.plan || "").replace(" - ", " ")}{o.linea || "—"}{o.faseActual || "—"}{o.diasEnFase == null ? "—" : o.diasEnFase} + + ● {o.actividad} + + {o.owner}{money2(o.est)}{money2(o.fc)}{o.close || "—"}
+
+
+ )} +
+ ); +} diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..6fe8f2a --- /dev/null +++ b/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + serverExternalPackages: ["better-sqlite3"], + images: { unoptimized: true }, +}; +module.exports = nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5b18e68 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1137 @@ +{ + "name": "informe-comercial", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "informe-comercial", + "version": "0.1.0", + "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" + } + }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.5.tgz", + "integrity": "sha512-NWEXVDMqoEo0ktmU6u0sE2Vg0LOcsD7NnOTJNo3/fEaTfsg+F1bMIxuDmQbda4e3yTIQwVdUREF2yIuMOusKtg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.5.tgz", + "integrity": "sha512-pMmGgETfKvElucLHtVaeiMRbp2zUbvKx7b1yGko0liBz3cw1mKSggWN/Rp/wPz8z+E1O82u3r4L1Co+ZS5hokQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.5.tgz", + "integrity": "sha512-76VaGYvf6HPa5/w12yLkE3dXTn9AfdEviI79oEL3aZoAmRLc9rWitjWqyjViVysK/ht/y9YKzFkBrUdi/wGkow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.5.tgz", + "integrity": "sha512-zKDELJ5jSQMHeO/hmXUQsAzagX4bQD4OiMi3pQ5FbUj+yK506oLVHnKA2YXMlbg1EHHqJYtyePOgByIDXD1lqw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.5.tgz", + "integrity": "sha512-7Vql0pgzCoHagv6+FNOZoqmJqA52c6zeVbhtS/47qFozO1MSx4ms7x7GHiciY8R5CDsSMKMQjJEryoJLcsBIbA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.5.tgz", + "integrity": "sha512-NH/xzehyHEFWE2nlcZon7TB/0+H4shfWCi7S1zka815XCOhJDYZhoeJtOYy0dh0WVRWACVXSyGNFFytoMxUhRg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.5.tgz", + "integrity": "sha512-lV4+EhWMfS8jcC+EH2nn/Cm5cn6XsgbE07bU9tMH8fCo0tNAqhyzi1b5wQ/Tn6NGFTvKDY65w3ZH95EjwBRAnQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.5.tgz", + "integrity": "sha512-/wKzAREX2RF++MhicjDbg8tGn2AiBIM0+EFeTFKoUEUbW5D6amCJehd5Z5G1H5/gxNdgnwoXMcHz24H/c2tGkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.5.tgz", + "integrity": "sha512-LNdCHzgLFc+UeqMS84LzXPaeBRKyqDN9OMyFAr1OrB0XrNw78IRrEVtZvvA7245W/HsaoeVOQX9jPjPk8jojwA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.5.tgz", + "integrity": "sha512-MdtsTgzyfCPRLC6uJ1mN8ao7lyJ4BB0U6Inhnx3gta1UcCIdHK3yxLG0E8OWQteWD8/Q0qb8A5o7wJaL8M9y2w==", + "license": "MIT", + "dependencies": { + "@next/env": "16.3.5", + "@swc/helpers": "0.5.23", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.5.23", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.3.5", + "@next/swc-darwin-x64": "16.3.5", + "@next/swc-linux-arm64-gnu": "16.3.5", + "@next/swc-linux-arm64-musl": "16.3.5", + "@next/swc-linux-x64-gnu": "16.3.5", + "@next/swc-linux-x64-musl": "16.3.5", + "@next/swc-win32-arm64-msvc": "16.3.5", + "@next/swc-win32-x64-msvc": "16.3.5", + "sharp": "^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/react": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", + "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", + "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7fa3681 --- /dev/null +++ b/package.json @@ -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 + } +} diff --git a/public/assets/css/base.css b/public/assets/css/base.css new file mode 100644 index 0000000..a905320 --- /dev/null +++ b/public/assets/css/base.css @@ -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;} diff --git a/public/assets/css/components.css b/public/assets/css/components.css new file mode 100644 index 0000000..02e7e37 --- /dev/null +++ b/public/assets/css/components.css @@ -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);} diff --git a/public/assets/css/layout.css b/public/assets/css/layout.css new file mode 100644 index 0000000..f26977f --- /dev/null +++ b/public/assets/css/layout.css @@ -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;} +} diff --git a/public/assets/fonts/UbuntuSans-Bold.ttf b/public/assets/fonts/UbuntuSans-Bold.ttf new file mode 100644 index 0000000..b4743a0 Binary files /dev/null and b/public/assets/fonts/UbuntuSans-Bold.ttf differ diff --git a/public/assets/fonts/UbuntuSans-Regular.ttf b/public/assets/fonts/UbuntuSans-Regular.ttf new file mode 100644 index 0000000..fe8a43a Binary files /dev/null and b/public/assets/fonts/UbuntuSans-Regular.ttf differ diff --git a/public/assets/img/logo-alfa-blanco.png b/public/assets/img/logo-alfa-blanco.png new file mode 100644 index 0000000..cd94c37 Binary files /dev/null and b/public/assets/img/logo-alfa-blanco.png differ