diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b8f5fa9 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npx prisma *)" + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0087db0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Auto detect text files and perform LF normalization +* text=auto +*.sh text eol=lf +*.bat text eol=crlf +*.xlsx binary +*.png binary +*.ico binary diff --git a/.gitignore b/.gitignore index f390d12..4c72762 100644 --- a/.gitignore +++ b/.gitignore @@ -32,12 +32,31 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel +# python api (api/) +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.ruff_cache/ + # typescript *.tsbuildinfo next-env.d.ts /src/generated/prisma + +# business files (never commit quotation spreadsheets) +*.xlsx + +# editor temp files +*.tmp.* + +# claude local settings +.claude/settings.local.json diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..1864ac7 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,14 @@ +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=cotizador_e3 + +# Auth +API_KEY=change-me-to-a-secure-random-key +JWT_SECRET=change-me-to-a-secure-jwt-secret + +# Server +API_HOST=0.0.0.0 +API_PORT=8000 diff --git a/docs/paquetes-recomendados-pyme.md b/docs/paquetes-recomendados-pyme.md new file mode 100644 index 0000000..c92691c --- /dev/null +++ b/docs/paquetes-recomendados-pyme.md @@ -0,0 +1,124 @@ +# Paquetes recomendados PyME — Guía de venta + +Esta guía describe los **6 paquetes recomendados** que vienen pre-armados en el cotizador. Cada uno agrupa servicios del catálogo por las 4 fases de E3 (Auditoría → Setup → Publicidad/Manejo → Contenido/SEO) y está pensado para un **tipo de cliente PyME** según su **etapa de madurez** y su **giro de negocio**. + +> **Cómo usarlos:** en *Cotizaciones → Nueva*, usa el selector **"Iniciar desde Paquete"**, elige el paquete que mejor describe al cliente y pulsa **"Cargar servicios"**. Se precargan todos los servicios con su precio base; a partir de ahí ajustas, quitas o agregas lo que haga falta. Son un punto de partida, no una camisa de fuerza. + +> **Importante sobre precios:** las cifras de abajo son **base, sin IVA (16%)** y **sin presupuesto de pauta** (la inversión en anuncios de Google/Meta la pone el cliente directo en las plataformas, aparte de nuestros honorarios). Son orientativas para comparar paquetes. + +--- + +## Matriz etapa × giro + +| | **Inicio** | **Crecimiento** | **Escalamiento** | +|---|---|---|---| +| **Cualquier giro** | Arranque Esencial | — | Escalamiento 360 Integral | +| **Marca nueva / personal** | Marca + Presencia | — | — | +| **Local (restaurante, consultorio, tienda física)** | — | Negocio Local en Crecimiento | — | +| **Servicios / B2B** | — | Servicios / B2B: Generación de Leads | — | +| **Ecommerce** | Ecommerce Lanzamiento | Ecommerce Lanzamiento | (escala dentro del 360) | + +**Mezcla inteligente:** usamos servicios **genéricos** (precio fijo en MXN) donde el alcance es claro —auditorías, sitio WordPress, ecommerce, CRM, contenido, SEO mensual— y servicios **por nivel** (Emprendedor/PYME/Empresario) donde el escalonamiento aporta —logo, página web, Google/Meta Ads, manejo de redes en los tiers altos. + +--- + +## 1. Arranque Esencial +**Etapa:** Inicio · **Giro:** cualquiera · **Cuándo:** PyME o emprendedor que arranca de cero con presupuesto acotado. + +Lo mínimo viable para existir digitalmente: presencia profesional en redes y en Google, más un manejo básico para no abandonar las cuentas. + +| Fase | Servicios | +|---|---| +| F0 Auditoría | Auditoría en Redes Sociales | +| F1 Setup | Creación y Optimización de Redes Sociales · Ficha de Google My Business | +| F2 Manejo | Manejo Básico de Redes Sociales *(mensual)* | + +**Inversión aprox.:** ~$7,490 único + $3,000/mes. + +--- + +## 2. Marca + Presencia +**Etapa:** Inicio · **Giro:** marca nueva / personal · **Cuándo:** negocio nuevo que primero necesita imagen profesional antes de invertir fuerte en pauta. + +Suma identidad de marca (nivel Emprendedor) y un primer empuje de alcance con Meta Ads, además de un plan de contenido que ordene la comunicación. + +| Fase | Servicios | +|---|---| +| F0 Auditoría | Auditoría en Redes Sociales | +| F1 Setup | Logo e Identidad *(Emprendedor)* · Creación y Optimización de Redes Sociales · Ficha de Google My Business | +| F2 Manejo | Manejo Básico de Redes Sociales *(mensual)* · Manejo y Optimización de Meta Ads *(mensual)* | +| F3 Contenido | Desarrollo de Plan de Contenido | + +**Inversión aprox.:** ~$17,293 único + $4,500/mes. + +--- + +## 3. Negocio Local en Crecimiento +**Etapa:** Crecimiento · **Giro:** local (restaurante, consultorio, tienda física, servicios de zona) · **Cuándo:** ya opera y necesita que lo encuentren en su zona y le escriban/llamen. + +Enfoque en visibilidad local: una página web sencilla, ficha de Google bien optimizada, redes activas, Meta Ads para captación local y SEO local para aparecer en búsquedas cercanas. + +| Fase | Servicios | +|---|---| +| F0 Auditoría | Auditoría en Sitio Web | +| F1 Setup | Página Web *(Emprendedor)* · Ficha de Google My Business · Creación y Optimización de Redes Sociales | +| F2 Manejo | Manejo Básico de Redes Sociales *(mensual)* · Manejo y Optimización de Meta Ads *(mensual)* | +| F3 SEO | Posicionamiento SEO *(local — mensual)* | + +**Inversión aprox.:** ~$15,258 único + $7,400/mes. + +--- + +## 4. Servicios / B2B: Generación de Leads +**Etapa:** Crecimiento · **Giro:** servicios profesionales / B2B · **Cuándo:** empresa de servicios que quiere un motor de prospección serio. + +El paquete más orientado a generar y gestionar prospectos: sitio web robusto en WordPress, CRM Bucéfalo para no perder ningún lead, campañas de Google Ads y contenido + SEO para tracción orgánica. + +| Fase | Servicios | +|---|---| +| F0 Auditoría | Auditoría en Sitio Web · Estudio Digital de Mercado | +| F1 Setup | Diseño de Sitio Web (WordPress) · Puesta en Marcha del CRM Bucéfalo · Setup de Google Ads y Meta Ads | +| F2 Manejo | Configuración y Optimización Google Ads *(mensual)* · Plan Mensual CRM Bucéfalo *(mensual)* | +| F3 Contenido/SEO | Desarrollo de Plan de Contenido · Posicionamiento SEO *(mensual)* | + +**Inversión aprox.:** ~$53,690 único + $11,700/mes. + +--- + +## 5. Ecommerce Lanzamiento +**Etapa:** Inicio / Crecimiento · **Giro:** ecommerce · **Cuándo:** negocio que quiere vender en línea desde cero con buen setup de medición y pauta. + +Tienda WooCommerce lista para vender, medición y pixeles bien configurados, CRM para seguimiento, y campañas en Google y Meta para traer tráfico que compre. + +| Fase | Servicios | +|---|---| +| F0 Auditoría | Auditoría en Ecommerce | +| F1 Setup | Diseño de Ecommerce (WooCommerce) · Setup de Google Ads y Meta Ads · Puesta en Marcha del CRM Bucéfalo | +| F2 Manejo | Manejo y Optimización de Meta Ads *(mensual)* · Configuración y Optimización Google Ads *(mensual)* | + +**Inversión aprox.:** ~$30,790 único + $6,400/mes. + +--- + +## 6. Escalamiento 360 Integral +**Etapa:** Escalamiento · **Giro:** negocio consolidado, cualquier giro · **Cuándo:** cliente con estructura que quiere acelerar y delegar todo el frente digital. + +El paquete tope: auditorías completas, estudio de mercado, identidad y sitio, CRM, ads en niveles altos (Empresario), manejo de redes profesional, contenido continuo, SEO completo y un Plan de Acompañamiento Integral que da volante estratégico. **Es deliberadamente amplio**: el asesor recorta según presupuesto y prioridades del cliente. + +| Fase | Servicios | +|---|---| +| F0 Auditoría/Acomp. | Auditoría en Sitio Web · Auditoría de cuentas Google Ads y Meta Ads · Estudio Digital de Mercado · Plan de Acompañamiento Integral *(mensual)* | +| F1 Setup | Diseño de Identidad Corporativa · Diseño de Sitio Web (WordPress) · Puesta en Marcha del CRM Bucéfalo · Setup de Google Ads y Meta Ads | +| F2 Manejo | Google Ads *(Empresario — mensual)* · Facebook & Instagram Ads *(Empresario — mensual)* · Manejo de Redes Sociales *(PYME — mensual)* · Plan Mensual CRM Bucéfalo *(mensual)* | +| F3 Contenido/SEO | Desarrollo de Plan de Contenido · Generación de Contenido *(mensual)* · Estrategia SEO Completa *(mensual)* | + +**Inversión aprox.:** ~$90,800 único + $54,159/mes (tope; normalmente se ajusta a la baja). + +--- + +## Notas para el asesor + +- **El paquete "General"** sigue existiendo y contiene *todo* el catálogo: úsalo cuando quieras partir de cero y elegir servicio por servicio. +- **Niveles:** donde un paquete usa un servicio "por nivel" (ej. Página Web Emprendedor, Google Ads Empresario), puedes subir o bajar de nivel cambiando el servicio en la cotización según el tamaño real del cliente. +- **Pauta y Bucéfalo:** recuerda que el presupuesto de anuncios y la mensualidad del plan Bucéfalo CRM se cotizan/aclaran aparte de nuestros honorarios. +- **Financiamiento:** los montos únicos altos (paquetes 4, 5 y 6) son buenos candidatos para ofrecer los planes de financiamiento a 3/6/9/12 meses. diff --git a/prisma/migrations/20260601000000_add_servicio_personalizado_horas/migration.sql b/prisma/migrations/20260601000000_add_servicio_personalizado_horas/migration.sql new file mode 100644 index 0000000..a5bbb97 --- /dev/null +++ b/prisma/migrations/20260601000000_add_servicio_personalizado_horas/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable: permitir servicios sin catalogo (partidas personalizadas) y campos de cobro por horas +ALTER TABLE "ServicioCotizado" ALTER COLUMN "servicioCatalogoId" DROP NOT NULL; +ALTER TABLE "ServicioCotizado" ADD COLUMN "nombre" TEXT; +ALTER TABLE "ServicioCotizado" ADD COLUMN "esPersonalizado" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "ServicioCotizado" ADD COLUMN "horas" DOUBLE PRECISION; +ALTER TABLE "ServicioCotizado" ADD COLUMN "tarifaHora" DOUBLE PRECISION; diff --git a/prisma/migrations/20260603085717_add_modelo_cobro_retainer/migration.sql b/prisma/migrations/20260603085717_add_modelo_cobro_retainer/migration.sql new file mode 100644 index 0000000..14157e2 --- /dev/null +++ b/prisma/migrations/20260603085717_add_modelo_cobro_retainer/migration.sql @@ -0,0 +1,10 @@ +-- DropForeignKey +ALTER TABLE "ServicioCotizado" DROP CONSTRAINT "ServicioCotizado_servicioCatalogoId_fkey"; + +-- AlterTable +ALTER TABLE "ServicioCotizado" ADD COLUMN "horasIncluidas" DOUBLE PRECISION, +ADD COLUMN "modeloCobro" TEXT NOT NULL DEFAULT 'fijo', +ADD COLUMN "montoMinimo" DOUBLE PRECISION; + +-- AddForeignKey +ALTER TABLE "ServicioCotizado" ADD CONSTRAINT "ServicioCotizado_servicioCatalogoId_fkey" FOREIGN KEY ("servicioCatalogoId") REFERENCES "ServicioCatalogo"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260603090526_add_nivel_servicio/migration.sql b/prisma/migrations/20260603090526_add_nivel_servicio/migration.sql new file mode 100644 index 0000000..a61cf13 --- /dev/null +++ b/prisma/migrations/20260603090526_add_nivel_servicio/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ServicioCatalogo" ADD COLUMN "nivel" TEXT; diff --git a/prisma/migrations/20260609224848_doble_propuesta/migration.sql b/prisma/migrations/20260609224848_doble_propuesta/migration.sql new file mode 100644 index 0000000..522849e --- /dev/null +++ b/prisma/migrations/20260609224848_doble_propuesta/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "Cotizacion" ADD COLUMN "esDoble" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "opcionesMetadata" JSONB; + +-- AlterTable +ALTER TABLE "ServicioCotizado" ADD COLUMN "opcion" TEXT; diff --git a/prisma/migrations/20260610012632_add_performance_indexes/migration.sql b/prisma/migrations/20260610012632_add_performance_indexes/migration.sql new file mode 100644 index 0000000..287924d --- /dev/null +++ b/prisma/migrations/20260610012632_add_performance_indexes/migration.sql @@ -0,0 +1,32 @@ +-- CreateIndex +CREATE INDEX "Cliente_nombre_empresa_idx" ON "Cliente"("nombre", "empresa"); + +-- CreateIndex +CREATE INDEX "Cotizacion_clienteId_idx" ON "Cotizacion"("clienteId"); + +-- CreateIndex +CREATE INDEX "Cotizacion_asesorId_idx" ON "Cotizacion"("asesorId"); + +-- CreateIndex +CREATE INDEX "Cotizacion_estado_idx" ON "Cotizacion"("estado"); + +-- CreateIndex +CREATE INDEX "Cotizacion_createdAt_idx" ON "Cotizacion"("createdAt"); + +-- CreateIndex +CREATE INDEX "FasePaquete_paqueteId_idx" ON "FasePaquete"("paqueteId"); + +-- CreateIndex +CREATE INDEX "ServicioCatalogo_categoriaId_idx" ON "ServicioCatalogo"("categoriaId"); + +-- CreateIndex +CREATE INDEX "ServicioCatalogo_activo_fase_orden_idx" ON "ServicioCatalogo"("activo", "fase", "orden"); + +-- CreateIndex +CREATE INDEX "ServicioCotizado_cotizacionId_idx" ON "ServicioCotizado"("cotizacionId"); + +-- CreateIndex +CREATE INDEX "ServicioCotizado_servicioCatalogoId_idx" ON "ServicioCotizado"("servicioCatalogoId"); + +-- CreateIndex +CREATE INDEX "ServicioPaquete_fasePaqueteId_idx" ON "ServicioPaquete"("fasePaqueteId"); diff --git a/prisma/seed.ts b/prisma/seed.ts index e84fb1d..682c524 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,41 +3,54 @@ import { PrismaPg } from "@prisma/adapter-pg"; import { hash } from "bcryptjs"; const adapter = new PrismaPg({ - host: "localhost", - port: 5432, - user: "postgres", - password: "postgres", - database: "cotizador_e3", + host: process.env.DB_HOST || "localhost", + port: parseInt(process.env.DB_PORT || "5432"), + user: process.env.DB_USER || "postgres", + password: process.env.DB_PASSWORD || "postgres", + database: process.env.DB_NAME || "cotizador_e3", }); const prisma = new PrismaClient({ adapter }); async function main() { console.log("Seeding database..."); - const adminHash = await hash("cee564F1.", 10); - const asesorHash = await hash("asesor123", 10); + // Credentials come from env. Without SEED_*_PASSWORD the user is created + // with a default password only if it doesn't exist yet (never overwritten). + const adminEmail = process.env.SEED_ADMIN_EMAIL || "admin@e3.local"; + const adminPassword = process.env.SEED_ADMIN_PASSWORD; + if (!adminPassword) { + console.warn( + ` AVISO: SEED_ADMIN_PASSWORD no definida. Si el usuario ${adminEmail} no existe, se creará con password "admin123" — cámbiala de inmediato.` + ); + } + const adminHash = await hash(adminPassword || "admin123", 10); await prisma.user.upsert({ - where: { email: "urieljareth@gmail.com" }, - update: { password: adminHash }, + where: { email: adminEmail }, + update: adminPassword ? { password: adminHash } : {}, create: { - email: "urieljareth@gmail.com", + email: adminEmail, password: adminHash, - name: "Uriel Jareth Alvarado Ortiz", + name: process.env.SEED_ADMIN_NAME || "Administrador", role: "admin", }, }); - await prisma.user.upsert({ - where: { email: "asesor@urieljareth.com" }, - update: { password: asesorHash }, - create: { - email: "asesor@urieljareth.com", - password: asesorHash, - name: "Lorena Soto", - role: "asesor", - }, - }); + const asesorEmail = process.env.SEED_ASESOR_EMAIL; + if (asesorEmail) { + const asesorPassword = process.env.SEED_ASESOR_PASSWORD; + const asesorHash = await hash(asesorPassword || "asesor123", 10); + await prisma.user.upsert({ + where: { email: asesorEmail }, + update: asesorPassword ? { password: asesorHash } : {}, + create: { + email: asesorEmail, + password: asesorHash, + name: process.env.SEED_ASESOR_NAME || "Asesor", + role: "asesor", + }, + }); + } // ── CATEGORIAS (must be created BEFORE servicios for FK) ── const categorias = [ diff --git a/src/lib/excel-builder.ts b/src/lib/excel-builder.ts new file mode 100644 index 0000000..fdf2156 --- /dev/null +++ b/src/lib/excel-builder.ts @@ -0,0 +1,552 @@ +import ExcelJS from "exceljs"; +import { bucefaloPrecio, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators"; + +// ───────────────────────────────────────────────────────────────────────────── +// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador +// (POST con body) como la de cotización guardada (GET por id) construyen este +// objeto y delegan TODO el dibujado al builder, para que el layout viva en un +// solo lugar y se mantenga la paridad con el generador de Python. +// ───────────────────────────────────────────────────────────────────────────── + +export interface ExcelServicio { + nombre: string; + fase: number; + tipoPago: string; + precio: number; + tiempoEntrega: string; + entregables: string[]; + modeloCobro?: string | null; + esPersonalizado?: boolean; + horas?: number | null; + tarifaHora?: number | null; + montoMinimo?: number | null; + horasIncluidas?: number | null; + opcion?: string | null; +} + +export interface ExcelData { + razonSocial: string; + domicilioFiscal?: string; + numero: string; // "BORRADOR" o número real + clienteNombre: string; + clienteEmpresa: string; + asesorNombre: string; + fecha: Date; + vigencia: Date; + moneda: string; + tipoCambio: string; + proyecto: string; + esquemaPago: string; + servicios: ExcelServicio[]; + esDoble?: boolean; + opcionesMetadata?: { "1"?: MetaOpcion; "2"?: MetaOpcion } | null; + planBucefaloNivel?: string | null; + planBucefaloPrecio?: number | null; + colorPrimario: string; + colorSecundario: string; +} + +// Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex): +// "FF" = opaco, "18" = ~9% (tinte claro). Espejo de _argb() en el generador Python. +function argb(hex: string, alpha = "FF"): string { + let h = hex.replace("#", ""); + if (h.length > 6) h = h.slice(0, 6); + return alpha + h.toUpperCase(); +} + +// Texto de desglose por modelo de cobro (horas / retainer) para mostrar junto al servicio. +function detalleModelo(serv: ExcelServicio): string { + if (serv.modeloCobro === "retainer") { + return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0); + } + if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) { + return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`; + } + return ""; +} + +function applyThinBorder(cell: ExcelJS.Cell, color?: string) { + const bc: Partial = { style: "thin", color: { argb: color || "FFD1D5DB" } }; + cell.border = { top: bc, left: bc, bottom: bc, right: bc }; +} + +function fillRow(ws: ExcelJS.Worksheet, row: number, startCol: string, endCol: string, argb: string) { + for (let c = ws.getColumn(startCol).number; c <= ws.getColumn(endCol).number; c++) { + ws.getCell(row, c).fill = { type: "pattern", pattern: "solid", fgColor: { argb } }; + } +} + +// Suma de anchos (en unidades de carácter de Excel) de las columnas entre dos letras, inclusive. +// Sirve para saber cuántos caracteres caben en una celda combinada y así calcular su altura. +function mergedWidth(ws: ExcelJS.Worksheet, startCol: string, endCol: string): number { + let total = 0; + const start = ws.getColumn(startCol).number; + const end = ws.getColumn(endCol).number; + for (let c = start; c <= end; c++) total += Number(ws.getColumn(c).width ?? 8); + return total; +} + +// Estima la altura (pt) que necesita una fila para mostrar `text` envuelto en `widthChars`. +function estimateRowHeight(text: string, widthChars: number, fontSize = 10): number { + const cpl = Math.max(8, Math.floor(widthChars)); // caracteres por línea (conservador) + const lineH = fontSize <= 10 ? 14 : fontSize <= 11 ? 15.5 : fontSize <= 12 ? 17 : 20; + let lines = 0; + for (const seg of String(text ?? "").split("\n")) { + lines += Math.max(1, Math.ceil(seg.length / cpl)); + } + return Math.max(16, Math.round(lines * lineH + 4)); +} + +// Marca una celda como envolvente (wrapText) y agranda la fila si el texto lo requiere. +// Es la pieza que hace las celdas "dinámicas": el texto deja de salirse y la fila crece. +function setWrapped( + ws: ExcelJS.Worksheet, + row: number, + col: string, + text: string, + widthChars: number, + opts: { fontSize?: number; horizontal?: "left" | "right" | "center"; vertical?: "top" | "middle" } = {} +) { + const cell = ws.getCell(`${col}${row}`); + cell.alignment = { + wrapText: true, + vertical: opts.vertical ?? "top", + horizontal: opts.horizontal ?? "left", + }; + const needed = estimateRowHeight(text, widthChars, opts.fontSize ?? 10); + const r = ws.getRow(row); + if ((r.height ?? 0) < needed) r.height = needed; +} + +export async function buildCotizacionExcel(data: ExcelData): Promise { + const PRIMARY = argb(data.colorPrimario || "#2563eb"); + const PRIMARY_LIGHT = argb(data.colorPrimario || "#2563eb", "18"); + const SECONDARY = argb(data.colorSecundario || "#1e293b"); + const WHITE = "FFFFFFFF"; + const LIGHT_BG = "FFF8FAFC"; + const MUTED = "FF64748B"; + const BORDER_COLOR = "FFE2E8F0"; + + const workbook = new ExcelJS.Workbook(); + workbook.creator = "Cotizador E3"; + workbook.created = new Date(); + + const headerFont: Partial = { bold: true, size: 10, name: "Calibri", color: { argb: WHITE } }; + const labelFont: Partial = { bold: true, size: 10, name: "Calibri", color: { argb: MUTED } }; + const valueFont: Partial = { size: 10, name: "Calibri" }; + const boldFont: Partial = { bold: true, size: 10, name: "Calibri" }; + const totalFont: Partial = { bold: true, size: 11, name: "Calibri" }; + const smallFont: Partial = { italic: true, size: 9, name: "Calibri", color: { argb: MUTED } }; + const faseFont: Partial = { bold: true, size: 11, name: "Calibri", color: { argb: PRIMARY } }; + + const fases: Record = { 0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3" }; + + // ═══════════════════════════════════════════════════════════════════════════ + // HOJA RESUMEN + // ═══════════════════════════════════════════════════════════════════════════ + const ws = workbook.addWorksheet("HOJA RESUMEN"); + ws.columns = [ + { width: 3 }, // A gutter + { width: 18 }, // B label + { width: 26 }, // C value + { width: 13 }, // D + { width: 16 }, // E label + { width: 22 }, // F value + { width: 6 }, // G + { width: 6 }, // H + { width: 6 }, // I + { width: 6 }, // J + { width: 16 }, // K precio + ]; + ws.views = [{ showGridLines: false }]; + + // ── Banner ── + ws.mergeCells("B2:K2"); + ws.getCell("B2").value = data.razonSocial; + ws.getCell("B2").font = { bold: true, size: 16, name: "Calibri", color: { argb: WHITE } }; + ws.getCell("B2").alignment = { vertical: "middle" }; + fillRow(ws, 2, "A", "K", PRIMARY); + ws.getRow(2).height = 28; + + ws.mergeCells("B3:K3"); + ws.getCell("B3").value = data.numero; + ws.getCell("B3").font = { bold: true, size: 11, name: "Calibri", color: { argb: WHITE } }; + ws.getCell("B3").alignment = { vertical: "middle" }; + fillRow(ws, 3, "A", "K", SECONDARY); + + // ── Info cliente / proyecto ── + // Cada fila: [colLabel1, label1, colVal1, valor1, colLabel2, label2, colVal2, valor2] + const infoStart = 5; + const infoRows: [string, string, string, string, string, string, string, string][] = [ + ["B", "En atencion a:", "C", data.clienteNombre, "E", "Empresa:", "F", data.clienteEmpresa || "—"], + ["B", "Proyecto:", "C", data.proyecto, "E", "Moneda:", "F", data.moneda], + ["B", "Asesor:", "C", data.asesorNombre, "E", "Tipo de Cambio:", "F", data.tipoCambio], + ["B", "Fecha:", "C", "", "E", "Esquema de Pago:", "F", data.esquemaPago], + ]; + + const cValW = mergedWidth(ws, "C", "C"); + const fValW = mergedWidth(ws, "F", "F"); + for (let i = 0; i < infoRows.length; i++) { + const r = infoStart + i; + const [lc1, lv1, vc1, vv1, lc2, lv2, vc2, vv2] = infoRows[i]; + ws.getCell(`${lc1}${r}`).value = lv1; + ws.getCell(`${lc1}${r}`).font = labelFont; + ws.getCell(`${vc1}${r}`).value = vv1; + ws.getCell(`${vc1}${r}`).font = valueFont; + if (vv1) setWrapped(ws, r, vc1, vv1, cValW); + + ws.getCell(`${lc2}${r}`).value = lv2; + ws.getCell(`${lc2}${r}`).font = labelFont; + ws.getCell(`${vc2}${r}`).value = vv2; + ws.getCell(`${vc2}${r}`).font = valueFont; + if (vv2) setWrapped(ws, r, vc2, vv2, fValW); + } + + const fechaCell = ws.getCell(`C${infoStart + 3}`); + fechaCell.value = data.fecha; + fechaCell.numFmt = "DD/MM/YYYY"; + fechaCell.font = valueFont; + + const vigRow = infoStart + 4; + ws.getCell(`B${vigRow}`).value = "Vigencia:"; + ws.getCell(`B${vigRow}`).font = labelFont; + const vigenciaCell = ws.getCell(`C${vigRow}`); + vigenciaCell.value = data.vigencia; + vigenciaCell.numFmt = "DD/MM/YYYY"; + vigenciaCell.font = valueFont; + + // ── Tabla de servicios ── + const tableStart = infoStart + 6; + const colB = ws.getColumn("B").number; + const colK = ws.getColumn("K").number; + + // Estiliza (relleno + borde) todo el ancho de una fila de la tabla, de B a K. + const styleTableRow = (rowNum: number, fillArgb: string, borderArgb: string) => { + for (let c = colB; c <= colK; c++) { + const cell = ws.getCell(rowNum, c); + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: fillArgb } }; + applyThinBorder(cell, borderArgb); + } + }; + + ws.mergeCells(`D${tableStart}:J${tableStart}`); + styleTableRow(tableStart, PRIMARY, PRIMARY); + const headerCells: [string, string, "left" | "right"][] = [ + ["B", "Fase", "left"], + ["C", "Tipo de Pago", "left"], + ["D", "Servicio", "left"], + ["K", "Precio", "right"], + ]; + for (const [col, txt, al] of headerCells) { + const cell = ws.getCell(`${col}${tableStart}`); + cell.value = txt; + cell.font = headerFont; + cell.alignment = { horizontal: al, vertical: "middle" }; + } + ws.getRow(tableStart).height = 24; + + const serviciosUnicos = data.servicios.filter((s) => s.tipoPago === "unico"); + const serviciosMensuales = data.servicios.filter((s) => s.tipoPago === "mensual"); + const servWidth = mergedWidth(ws, "D", "J"); + + let row = tableStart + 1; + + // ── Totales (helpers reutilizados por modo normal y doble propuesta) ── + const writeTotalRow = (label: string, amount: number, font: Partial) => { + ws.mergeCells(`B${row}:J${row}`); + ws.getCell(`B${row}`).value = label; + ws.getCell(`B${row}`).font = font; + ws.getCell(`B${row}`).alignment = { vertical: "middle" }; + ws.getCell(`K${row}`).value = amount; + ws.getCell(`K${row}`).numFmt = "$#,##0.00"; + ws.getCell(`K${row}`).font = font; + ws.getCell(`K${row}`).alignment = { horizontal: "right", vertical: "middle" }; + styleTableRow(row, PRIMARY_LIGHT, PRIMARY); + row++; + }; + const writeIvaNote = () => { + ws.mergeCells(`B${row}:J${row}`); + ws.getCell(`B${row}`).value = "(+ IVA)"; + ws.getCell(`B${row}`).font = { ...smallFont, italic: false }; + row++; + }; + + // Dibuja las filas de servicios agrupadas por fase, a partir de la fila actual. + const drawServiceRows = (servs: ExcelServicio[]) => { + let currentFase = -1; + for (const serv of servs) { + if (serv.fase !== currentFase) { + currentFase = serv.fase; + ws.mergeCells(`B${row}:K${row}`); + ws.getCell(`B${row}`).value = fases[serv.fase] || `FASE ${serv.fase}`; + ws.getCell(`B${row}`).font = faseFont; + ws.getCell(`B${row}`).alignment = { vertical: "middle" }; + fillRow(ws, row, "B", "K", PRIMARY_LIGHT); + ws.getRow(row).height = 20; + row++; + } + const rowBg = row % 2 === 0 ? LIGHT_BG : WHITE; + ws.mergeCells(`D${row}:J${row}`); + styleTableRow(row, rowBg, BORDER_COLOR); + ws.getCell(`C${row}`).value = serv.tipoPago === "unico" ? "Unico" : "Mensual"; + ws.getCell(`C${row}`).font = valueFont; + ws.getCell(`C${row}`).alignment = { vertical: "top" }; + const detalle = detalleModelo(serv); + const servText = detalle ? `${serv.nombre} — ${detalle}` : serv.nombre; + ws.getCell(`D${row}`).value = servText; + ws.getCell(`D${row}`).font = valueFont; + setWrapped(ws, row, "D", servText, servWidth); + ws.getCell(`K${row}`).value = serv.precio; + ws.getCell(`K${row}`).numFmt = "$#,##0.00"; + ws.getCell(`K${row}`).font = valueFont; + ws.getCell(`K${row}`).alignment = { horizontal: "right", vertical: "top" }; + row++; + } + }; + + // Banner de una opcion (titulo + descripcion + exclusiones) en doble propuesta. + const writeOpcionBanner = (op: "1" | "2") => { + const meta = data.opcionesMetadata?.[op] || {}; + ws.mergeCells(`B${row}:K${row}`); + ws.getCell(`B${row}`).value = `OPCION ${op}${meta.titulo ? ": " + meta.titulo : ""}`; + ws.getCell(`B${row}`).font = { bold: true, size: 12, name: "Calibri", color: { argb: WHITE } }; + ws.getCell(`B${row}`).alignment = { vertical: "middle" }; + fillRow(ws, row, "B", "K", SECONDARY); + ws.getRow(row).height = 22; + row++; + if (meta.descripcion) { + ws.mergeCells(`B${row}:K${row}`); + ws.getCell(`B${row}`).value = meta.descripcion; + ws.getCell(`B${row}`).font = valueFont; + setWrapped(ws, row, "B", meta.descripcion, mergedWidth(ws, "B", "K")); + row++; + } + if (meta.noIncluye) { + ws.mergeCells(`B${row}:K${row}`); + const t = `No incluye: ${meta.noIncluye}`; + ws.getCell(`B${row}`).value = t; + ws.getCell(`B${row}`).font = smallFont; + setWrapped(ws, row, "B", t, mergedWidth(ws, "B", "K"), { fontSize: 9 }); + row++; + } + }; + + if (data.esDoble) { + for (const op of ["1", "2"] as const) { + const inOp = (s: ExcelServicio) => s.opcion === op || s.opcion === "ambas"; + const u = serviciosUnicos.filter(inOp); + const me = serviciosMensuales.filter(inOp); + writeOpcionBanner(op); + drawServiceRows([...u, ...me]); + row++; + writeTotalRow(`Total Pago Unico - Opcion ${op}`, u.reduce((s, x) => s + x.precio, 0), totalFont); + writeIvaNote(); + writeTotalRow(`Total Pago Mensual - Opcion ${op}`, me.reduce((s, x) => s + x.precio, 0), totalFont); + writeIvaNote(); + row++; + } + + // ── Comparativa de opciones (totales con IVA + horas) ── + const t1 = calcularTotalesOpcion(data.servicios, "1"); + const t2 = calcularTotalesOpcion(data.servicios, "2"); + ws.mergeCells(`B${row}:K${row}`); + ws.getCell(`B${row}`).value = "COMPARATIVA DE OPCIONES"; + ws.getCell(`B${row}`).font = { bold: true, size: 12, name: "Calibri", color: { argb: WHITE } }; + ws.getCell(`B${row}`).alignment = { vertical: "middle" }; + fillRow(ws, row, "B", "K", PRIMARY); + ws.getRow(row).height = 22; + row++; + const t1Tit = data.opcionesMetadata?.["1"]?.titulo; + const t2Tit = data.opcionesMetadata?.["2"]?.titulo; + const compRow = (lab: string, v1: string, v2: string, font: Partial) => { + ws.mergeCells(`B${row}:F${row}`); + ws.getCell(`B${row}`).value = lab; + ws.getCell(`B${row}`).font = font; + ws.mergeCells(`G${row}:H${row}`); + ws.getCell(`G${row}`).value = v1; + ws.getCell(`G${row}`).font = font; + ws.getCell(`G${row}`).alignment = { horizontal: "right" }; + ws.mergeCells(`I${row}:K${row}`); + ws.getCell(`I${row}`).value = v2; + ws.getCell(`I${row}`).font = font; + ws.getCell(`I${row}`).alignment = { horizontal: "right" }; + for (let c = colB; c <= colK; c++) applyThinBorder(ws.getCell(row, c), BORDER_COLOR); + row++; + }; + compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont); + compRow("Total unico (c/IVA)", formatCurrency(t1.totalUnico * 1.16), formatCurrency(t2.totalUnico * 1.16), valueFont); + compRow("Total mensual (c/IVA)", formatCurrency(t1.totalMensual * 1.16), formatCurrency(t2.totalMensual * 1.16), valueFont); + compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont); + row++; + } else { + drawServiceRows([...serviciosUnicos, ...serviciosMensuales]); + row++; + writeTotalRow("Total Pago Unico", serviciosUnicos.reduce((s, x) => s + x.precio, 0), totalFont); + writeIvaNote(); + writeTotalRow("Total Pago Mensual", serviciosMensuales.reduce((s, x) => s + x.precio, 0), totalFont); + writeIvaNote(); + row++; + } + + if (data.planBucefaloNivel) { + const precio = data.planBucefaloPrecio ?? bucefaloPrecio(data.planBucefaloNivel); + const nivel = data.planBucefaloNivel.charAt(0).toUpperCase() + data.planBucefaloNivel.slice(1); + writeTotalRow(`CRM Bucefalo - ${nivel}`, precio, boldFont); + row++; + } + + ws.mergeCells(`B${row}:K${row}`); + const notaResumen = + "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."; + ws.getCell(`B${row}`).value = notaResumen; + ws.getCell(`B${row}`).font = smallFont; + setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 }); + + // ═══════════════════════════════════════════════════════════════════════════ + // HOJAS DETALLADAS POR SERVICIO + // ═══════════════════════════════════════════════════════════════════════════ + const usedNames = new Set(); + for (const serv of data.servicios) { + // Nombre de hoja único y válido (Excel: máx 31 chars, sin / \ * ? [ ] :). + const base = serv.nombre.substring(0, 31).replace(/[/\\*?[\]:]/g, "") || "Servicio"; + let safeName = base; + let n = 2; + while (usedNames.has(safeName.toLowerCase())) { + const suffix = ` (${n})`; + safeName = base.substring(0, 31 - suffix.length) + suffix; + n++; + } + usedNames.add(safeName.toLowerCase()); + + const dw = workbook.addWorksheet(safeName); + dw.columns = [ + { width: 3 }, // A gutter + { width: 16 }, // B label / entregable + { width: 30 }, // C value + { width: 8 }, // D + { width: 4 }, // E + { width: 18 }, // F label + { width: 22 }, // G value + { width: 6 }, // H + { width: 6 }, // I precio + { width: 16 }, // J precio + ]; + dw.views = [{ showGridLines: false }]; + + // Banner + dw.mergeCells("B1:J1"); + const bannerText = `${fases[serv.fase] || "FASE " + serv.fase} — ${serv.nombre}`; + dw.getCell("B1").value = bannerText; + dw.getCell("B1").font = { bold: true, size: 14, name: "Calibri", color: { argb: WHITE } }; + dw.getCell("B1").alignment = { vertical: "middle", wrapText: true }; + fillRow(dw, 1, "A", "J", PRIMARY); + dw.getRow(1).height = Math.max(30, estimateRowHeight(bannerText, mergedWidth(dw, "B", "J"), 14)); + + const cVal = mergedWidth(dw, "C", "D"); + const gVal = mergedWidth(dw, "G", "H"); + + let r = 3; + dw.getCell(`B${r}`).value = "Cliente:"; + dw.getCell(`B${r}`).font = labelFont; + dw.mergeCells(`C${r}:D${r}`); + dw.getCell(`C${r}`).value = data.clienteEmpresa || data.clienteNombre; + dw.getCell(`C${r}`).font = boldFont; + setWrapped(dw, r, "C", data.clienteEmpresa || data.clienteNombre, cVal); + dw.getCell(`F${r}`).value = "No. Cotizacion:"; + dw.getCell(`F${r}`).font = labelFont; + dw.mergeCells(`G${r}:H${r}`); + dw.getCell(`G${r}`).value = data.numero; + dw.getCell(`G${r}`).font = boldFont; + r += 2; + + dw.getCell(`B${r}`).value = "Servicio:"; + dw.getCell(`B${r}`).font = labelFont; + dw.mergeCells(`C${r}:D${r}`); + dw.getCell(`C${r}`).value = serv.nombre; + dw.getCell(`C${r}`).font = { ...boldFont, size: 12, color: { argb: PRIMARY } }; + setWrapped(dw, r, "C", serv.nombre, cVal, { fontSize: 12 }); + dw.getCell(`F${r}`).value = "Tiempo de Entrega:"; + dw.getCell(`F${r}`).font = labelFont; + dw.mergeCells(`G${r}:H${r}`); + dw.getCell(`G${r}`).value = serv.tiempoEntrega; + dw.getCell(`G${r}`).font = valueFont; + setWrapped(dw, r, "G", serv.tiempoEntrega, gVal); + r += 2; + + const detalleServ = detalleModelo(serv); + if (detalleServ) { + dw.getCell(`B${r}`).value = "Detalle:"; + dw.getCell(`B${r}`).font = labelFont; + dw.mergeCells(`C${r}:J${r}`); + dw.getCell(`C${r}`).value = detalleServ; + dw.getCell(`C${r}`).font = valueFont; + setWrapped(dw, r, "C", detalleServ, mergedWidth(dw, "C", "J")); + r += 2; + } + + // Encabezado de entregables — barra completa B:J + dw.mergeCells(`B${r}:J${r}`); + dw.getCell(`B${r}`).value = "Entregables"; + dw.getCell(`B${r}`).font = headerFont; + dw.getCell(`B${r}`).alignment = { vertical: "middle" }; + fillRow(dw, r, "B", "J", PRIMARY); + dw.getRow(r).height = 22; + r++; + + const entWidth = mergedWidth(dw, "B", "J"); + for (let i = 0; i < serv.entregables.length; i++) { + const er = r + i; + dw.mergeCells(`B${er}:J${er}`); + const txt = `${i + 1}. ${serv.entregables[i]}`; + dw.getCell(`B${er}`).value = txt; + dw.getCell(`B${er}`).font = valueFont; + setWrapped(dw, er, "B", txt, entWidth); + const bg = i % 2 === 1 ? LIGHT_BG : WHITE; + for (let c = dw.getColumn("B").number; c <= dw.getColumn("J").number; c++) { + dw.getCell(er, c).fill = { type: "pattern", pattern: "solid", fgColor: { argb: bg } }; + applyThinBorder(dw.getCell(er, c), BORDER_COLOR); + } + } + r += serv.entregables.length + 1; + + // Total: etiqueta B:H + precio I:J + dw.mergeCells(`B${r}:H${r}`); + dw.getCell(`B${r}`).value = `Total Pago ${serv.tipoPago === "unico" ? "Unico" : "Mensual"}`; + dw.getCell(`B${r}`).font = totalFont; + dw.getCell(`B${r}`).alignment = { vertical: "middle" }; + dw.mergeCells(`I${r}:J${r}`); + dw.getCell(`I${r}`).value = serv.precio; + dw.getCell(`I${r}`).numFmt = "$#,##0.00"; + dw.getCell(`I${r}`).font = totalFont; + dw.getCell(`I${r}`).alignment = { horizontal: "right", vertical: "middle" }; + for (let c = dw.getColumn("B").number; c <= dw.getColumn("J").number; c++) { + dw.getCell(r, c).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } }; + applyThinBorder(dw.getCell(r, c), PRIMARY); + } + r++; + dw.getCell(`B${r}`).value = "(+ IVA)"; + dw.getCell(`B${r}`).font = { ...smallFont, italic: false }; + r += 2; + + dw.mergeCells(`B${r}:J${r}`); + const notaDetalle = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA."; + dw.getCell(`B${r}`).value = notaDetalle; + dw.getCell(`B${r}`).font = smallFont; + setWrapped(dw, r, "B", notaDetalle, mergedWidth(dw, "B", "J"), { fontSize: 9 }); + r += 2; + + dw.mergeCells(`B${r}:J${r}`); + dw.getCell(`B${r}`).value = data.razonSocial; + dw.getCell(`B${r}`).font = { ...boldFont, color: { argb: PRIMARY } }; + if (data.domicilioFiscal) { + r++; + dw.mergeCells(`B${r}:J${r}`); + dw.getCell(`B${r}`).value = data.domicilioFiscal; + dw.getCell(`B${r}`).font = { ...smallFont, italic: false }; + setWrapped(dw, r, "B", data.domicilioFiscal, mergedWidth(dw, "B", "J"), { fontSize: 9 }); + } + } + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +}