1
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx prisma *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+19
@@ -32,12 +32,31 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# python api (api/)
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# business files (never commit quotation spreadsheets)
|
||||||
|
*.xlsx
|
||||||
|
|
||||||
|
# editor temp files
|
||||||
|
*.tmp.*
|
||||||
|
|
||||||
|
# claude local settings
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ServicioCatalogo" ADD COLUMN "nivel" TEXT;
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
+34
-21
@@ -3,41 +3,54 @@ import { PrismaPg } from "@prisma/adapter-pg";
|
|||||||
import { hash } from "bcryptjs";
|
import { hash } from "bcryptjs";
|
||||||
|
|
||||||
const adapter = new PrismaPg({
|
const adapter = new PrismaPg({
|
||||||
host: "localhost",
|
host: process.env.DB_HOST || "localhost",
|
||||||
port: 5432,
|
port: parseInt(process.env.DB_PORT || "5432"),
|
||||||
user: "postgres",
|
user: process.env.DB_USER || "postgres",
|
||||||
password: "postgres",
|
password: process.env.DB_PASSWORD || "postgres",
|
||||||
database: "cotizador_e3",
|
database: process.env.DB_NAME || "cotizador_e3",
|
||||||
});
|
});
|
||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("Seeding database...");
|
console.log("Seeding database...");
|
||||||
|
|
||||||
const adminHash = await hash("cee564F1.", 10);
|
// Credentials come from env. Without SEED_*_PASSWORD the user is created
|
||||||
const asesorHash = await hash("asesor123", 10);
|
// with a default password only if it doesn't exist yet (never overwritten).
|
||||||
|
const adminEmail = process.env.SEED_ADMIN_EMAIL || "[email protected]";
|
||||||
|
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({
|
await prisma.user.upsert({
|
||||||
where: { email: "[email protected]" },
|
where: { email: adminEmail },
|
||||||
update: { password: adminHash },
|
update: adminPassword ? { password: adminHash } : {},
|
||||||
create: {
|
create: {
|
||||||
email: "[email protected]",
|
email: adminEmail,
|
||||||
password: adminHash,
|
password: adminHash,
|
||||||
name: "Uriel Jareth Alvarado Ortiz",
|
name: process.env.SEED_ADMIN_NAME || "Administrador",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.user.upsert({
|
const asesorEmail = process.env.SEED_ASESOR_EMAIL;
|
||||||
where: { email: "[email protected]" },
|
if (asesorEmail) {
|
||||||
update: { password: asesorHash },
|
const asesorPassword = process.env.SEED_ASESOR_PASSWORD;
|
||||||
create: {
|
const asesorHash = await hash(asesorPassword || "asesor123", 10);
|
||||||
email: "[email protected]",
|
await prisma.user.upsert({
|
||||||
password: asesorHash,
|
where: { email: asesorEmail },
|
||||||
name: "Lorena Soto",
|
update: asesorPassword ? { password: asesorHash } : {},
|
||||||
role: "asesor",
|
create: {
|
||||||
},
|
email: asesorEmail,
|
||||||
});
|
password: asesorHash,
|
||||||
|
name: process.env.SEED_ASESOR_NAME || "Asesor",
|
||||||
|
role: "asesor",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── CATEGORIAS (must be created BEFORE servicios for FK) ──
|
// ── CATEGORIAS (must be created BEFORE servicios for FK) ──
|
||||||
const categorias = [
|
const categorias = [
|
||||||
|
|||||||
@@ -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<ExcelJS.Border> = { 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<Buffer> {
|
||||||
|
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<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: WHITE } };
|
||||||
|
const labelFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: MUTED } };
|
||||||
|
const valueFont: Partial<ExcelJS.Font> = { size: 10, name: "Calibri" };
|
||||||
|
const boldFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri" };
|
||||||
|
const totalFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri" };
|
||||||
|
const smallFont: Partial<ExcelJS.Font> = { italic: true, size: 9, name: "Calibri", color: { argb: MUTED } };
|
||||||
|
const faseFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri", color: { argb: PRIMARY } };
|
||||||
|
|
||||||
|
const fases: Record<number, string> = { 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<ExcelJS.Font>) => {
|
||||||
|
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<ExcelJS.Font>) => {
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user