Restaurar funcionalidades desde stash + setup de despliegue Coolify
- Recupera el trabajo revertido (doble propuesta, retainer, niveles, API Python) desde el stash de GitHub Desktop - Dockerfile multi-stage para Next.js (migraciones automáticas al arrancar, seed opcional via RUN_SEED) - docker-compose.coolify.yml: postgres + web + api con healthchecks y SERVICE_FQDN_* - Endpoint público /api/health (verifica BD) para healthchecks - seed.ts parametrizado: conexión DB_* y credenciales SEED_* por entorno (sin passwords hardcodeadas) - .env.example, .dockerignore, uvicorn con --proxy-headers - Limpieza: .xlsx y __pycache__ fuera del repo Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -1,23 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import ExcelJS from "exceljs";
|
||||
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
|
||||
import { calcularVigencia, sanitizeFilename } from "@/lib/calculators";
|
||||
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
|
||||
|
||||
function hexToArgb(hex: string): string {
|
||||
const h = hex.replace("#", "");
|
||||
return "FF" + h.toUpperCase();
|
||||
}
|
||||
|
||||
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 } };
|
||||
}
|
||||
}
|
||||
import { buildCotizacionExcel, type ExcelData } from "@/lib/excel-builder";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -40,303 +24,66 @@ export async function POST(request: NextRequest) {
|
||||
precio: number;
|
||||
tiempoEntrega: string;
|
||||
entregables: string[];
|
||||
esPersonalizado?: boolean;
|
||||
horas?: number;
|
||||
tarifaHora?: number;
|
||||
modeloCobro?: string;
|
||||
montoMinimo?: number;
|
||||
horasIncluidas?: number;
|
||||
opcion?: string;
|
||||
}[];
|
||||
planBucefaloNivel: string | null;
|
||||
observaciones: string;
|
||||
esDoble?: boolean;
|
||||
opciones?: { "1"?: { titulo?: string; descripcion?: string; noIncluye?: string }; "2"?: { titulo?: string; descripcion?: string; noIncluye?: string } };
|
||||
};
|
||||
};
|
||||
|
||||
const fechaCot = new Date(draft.fecha);
|
||||
const vigencia = calcularVigencia(fechaCot);
|
||||
const empresa = draft.clienteEmpresa || draft.clienteNombre;
|
||||
|
||||
const branding = await getConfigBranding();
|
||||
const bancaria = await getConfigBancaria();
|
||||
const configColor = branding.colorPrimario || "#2563eb";
|
||||
const PRIMARY = hexToArgb(configColor);
|
||||
const PRIMARY_LIGHT = hexToArgb(configColor + "18");
|
||||
const SECONDARY = hexToArgb(branding.colorSecundario || "#1e293b");
|
||||
const WHITE = "FFFFFFFF";
|
||||
const LIGHT_BG = "FFF8FAFC";
|
||||
const MUTED = "FF64748B";
|
||||
const BORDER_COLOR = "FFE2E8F0";
|
||||
const [branding, bancaria] = await Promise.all([
|
||||
getConfigBranding(),
|
||||
getConfigBancaria(),
|
||||
]);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = "Cotizador E3";
|
||||
workbook.created = new Date();
|
||||
const data: ExcelData = {
|
||||
razonSocial: bancaria.razon_social || "Cotizador E3",
|
||||
domicilioFiscal: bancaria.domicilio_fiscal || undefined,
|
||||
numero: "BORRADOR",
|
||||
clienteNombre: draft.clienteNombre,
|
||||
clienteEmpresa: draft.clienteEmpresa,
|
||||
asesorNombre: draft.asesorNombre,
|
||||
fecha: fechaCot,
|
||||
vigencia: calcularVigencia(fechaCot),
|
||||
moneda: draft.moneda,
|
||||
tipoCambio: draft.tipoCambio,
|
||||
proyecto: draft.proyecto,
|
||||
esquemaPago: draft.esquemaPago,
|
||||
servicios: draft.servicios.map((s) => ({
|
||||
nombre: s.nombre,
|
||||
fase: s.fase,
|
||||
tipoPago: s.tipoPago,
|
||||
precio: s.precio,
|
||||
tiempoEntrega: s.tiempoEntrega,
|
||||
entregables: s.entregables ?? [],
|
||||
modeloCobro: s.modeloCobro,
|
||||
esPersonalizado: s.esPersonalizado,
|
||||
horas: s.horas,
|
||||
tarifaHora: s.tarifaHora,
|
||||
montoMinimo: s.montoMinimo,
|
||||
horasIncluidas: s.horasIncluidas,
|
||||
opcion: s.opcion,
|
||||
})),
|
||||
esDoble: draft.esDoble,
|
||||
opcionesMetadata: draft.esDoble ? draft.opciones ?? null : null,
|
||||
planBucefaloNivel: draft.planBucefaloNivel,
|
||||
colorPrimario: branding.colorPrimario || "#2563eb",
|
||||
colorSecundario: branding.colorSecundario || "#1e293b",
|
||||
};
|
||||
|
||||
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 ws = workbook.addWorksheet("HOJA RESUMEN");
|
||||
ws.columns = [
|
||||
{ width: 3 },
|
||||
{ width: 22 }, { width: 22 }, { width: 16 },
|
||||
{ width: 38 }, { width: 5 },
|
||||
{ width: 20 }, { width: 5 },
|
||||
{ width: 20 }, { width: 5 },
|
||||
{ width: 18 },
|
||||
];
|
||||
ws.views = [{ showGridLines: false }];
|
||||
|
||||
ws.mergeCells("B2:K2");
|
||||
ws.getCell("B2").value = bancaria.razon_social || "Cotizador E3";
|
||||
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.mergeCells("B3:K3");
|
||||
ws.getCell("B3").value = "BORRADOR";
|
||||
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);
|
||||
|
||||
const infoStart = 5;
|
||||
const infoLabels: [string, string, string, string][] = [
|
||||
["B", "En atencion a:", "C", draft.clienteNombre],
|
||||
["E", "Empresa:", "F", draft.clienteEmpresa || "—"],
|
||||
["B", "Proyecto:", "C", draft.proyecto],
|
||||
["E", "Moneda:", "F", draft.moneda],
|
||||
["B", "Asesor:", "C", draft.asesorNombre],
|
||||
["E", "Tipo de Cambio:", "F", draft.tipoCambio],
|
||||
["B", "Fecha:", "C", ""],
|
||||
["E", "Esquema de Pago:", "F", draft.esquemaPago],
|
||||
];
|
||||
|
||||
for (let i = 0; i < infoLabels.length; i += 2) {
|
||||
const r = infoStart + (i / 2);
|
||||
const [lc1, lv1, vc1, vv1] = infoLabels[i];
|
||||
const [lc2, lv2, vc2, vv2] = infoLabels[i + 1];
|
||||
|
||||
ws.getCell(`${lc1}${r}`).value = lv1;
|
||||
ws.getCell(`${lc1}${r}`).font = labelFont;
|
||||
ws.getCell(`${vc1}${r}`).value = vv1;
|
||||
ws.getCell(`${vc1}${r}`).font = valueFont;
|
||||
|
||||
ws.getCell(`${lc2}${r}`).value = lv2;
|
||||
ws.getCell(`${lc2}${r}`).font = labelFont;
|
||||
ws.getCell(`${vc2}${r}`).value = vv2;
|
||||
ws.getCell(`${vc2}${r}`).font = valueFont;
|
||||
}
|
||||
|
||||
const fechaCell = ws.getCell(`C${infoStart + 6}`);
|
||||
fechaCell.value = fechaCot;
|
||||
fechaCell.numFmt = "DD/MM/YYYY";
|
||||
fechaCell.font = valueFont;
|
||||
|
||||
ws.getCell(`B${infoStart + 7}`).value = "Vigencia:";
|
||||
ws.getCell(`B${infoStart + 7}`).font = labelFont;
|
||||
const vigenciaCell = ws.getCell(`C${infoStart + 7}`);
|
||||
vigenciaCell.value = vigencia;
|
||||
vigenciaCell.numFmt = "DD/MM/YYYY";
|
||||
vigenciaCell.font = valueFont;
|
||||
|
||||
const tableStart = infoStart + 9;
|
||||
const cols = ["B", "C", "D", "E", "K"];
|
||||
const headers = ["Fase", "Tipo de Pago", "Servicio", "", "Precio"];
|
||||
|
||||
ws.mergeCells(`D${tableStart}:J${tableStart}`);
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const cell = ws.getCell(`${cols[i]}${tableStart}`);
|
||||
cell.value = headers[i];
|
||||
cell.font = headerFont;
|
||||
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
|
||||
cell.alignment = { horizontal: i === cols.length - 1 ? "right" : "left", vertical: "middle" };
|
||||
applyThinBorder(cell, PRIMARY);
|
||||
}
|
||||
ws.getRow(tableStart).height = 24;
|
||||
|
||||
const fases: Record<number, string> = { 0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3" };
|
||||
const serviciosUnicos = draft.servicios.filter(s => s.tipoPago === "unico");
|
||||
const serviciosMensuales = draft.servicios.filter(s => s.tipoPago === "mensual");
|
||||
|
||||
let row = tableStart + 1;
|
||||
let currentFase = -1;
|
||||
|
||||
for (const serv of [...serviciosUnicos, ...serviciosMensuales]) {
|
||||
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;
|
||||
fillRow(ws, row, "B", "K", PRIMARY_LIGHT);
|
||||
row++;
|
||||
}
|
||||
|
||||
const isAlt = row % 2 === 0;
|
||||
const rowBg = isAlt ? LIGHT_BG : WHITE;
|
||||
|
||||
ws.getCell(`B${row}`).value = "";
|
||||
ws.getCell(`C${row}`).value = serv.tipoPago === "unico" ? "Unico" : "Mensual";
|
||||
ws.getCell(`C${row}`).font = valueFont;
|
||||
ws.mergeCells(`D${row}:J${row}`);
|
||||
ws.getCell(`D${row}`).value = serv.nombre;
|
||||
ws.getCell(`D${row}`).font = valueFont;
|
||||
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" };
|
||||
|
||||
for (const c of cols) {
|
||||
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: rowBg } };
|
||||
applyThinBorder(ws.getCell(`${c}${row}`), BORDER_COLOR);
|
||||
}
|
||||
row++;
|
||||
}
|
||||
|
||||
row++;
|
||||
const totalUnico = serviciosUnicos.reduce((s, x) => s + x.precio, 0);
|
||||
ws.mergeCells(`B${row}:J${row}`);
|
||||
ws.getCell(`B${row}`).value = "Total Pago Unico";
|
||||
ws.getCell(`B${row}`).font = totalFont;
|
||||
ws.getCell(`K${row}`).value = totalUnico;
|
||||
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
|
||||
ws.getCell(`K${row}`).font = totalFont;
|
||||
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
|
||||
for (const c of cols) {
|
||||
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
|
||||
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
|
||||
}
|
||||
row++;
|
||||
ws.mergeCells(`B${row}:J${row}`);
|
||||
ws.getCell(`B${row}`).value = "(+ IVA)";
|
||||
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
|
||||
row++;
|
||||
|
||||
const totalMensual = serviciosMensuales.reduce((s, x) => s + x.precio, 0);
|
||||
ws.mergeCells(`B${row}:J${row}`);
|
||||
ws.getCell(`B${row}`).value = "Total Pago Mensual";
|
||||
ws.getCell(`B${row}`).font = totalFont;
|
||||
ws.getCell(`K${row}`).value = totalMensual;
|
||||
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
|
||||
ws.getCell(`K${row}`).font = totalFont;
|
||||
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
|
||||
for (const c of cols) {
|
||||
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
|
||||
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
|
||||
}
|
||||
row++;
|
||||
ws.mergeCells(`B${row}:J${row}`);
|
||||
ws.getCell(`B${row}`).value = "(+ IVA)";
|
||||
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
|
||||
row += 2;
|
||||
|
||||
if (draft.planBucefaloNivel) {
|
||||
ws.mergeCells(`B${row}:J${row}`);
|
||||
ws.getCell(`B${row}`).value = `CRM Bucefalo - ${draft.planBucefaloNivel.charAt(0).toUpperCase() + draft.planBucefaloNivel.slice(1)}`;
|
||||
ws.getCell(`B${row}`).font = boldFont;
|
||||
ws.getCell(`K${row}`).value = bucefaloPrecio(draft.planBucefaloNivel);
|
||||
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
|
||||
ws.getCell(`K${row}`).font = boldFont;
|
||||
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
|
||||
for (const c of cols) {
|
||||
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
|
||||
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
|
||||
}
|
||||
row += 2;
|
||||
}
|
||||
|
||||
ws.mergeCells(`B${row}:K${row}`);
|
||||
ws.getCell(`B${row}`).value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles.";
|
||||
ws.getCell(`B${row}`).font = smallFont;
|
||||
|
||||
for (const serv of draft.servicios) {
|
||||
const safeName = serv.nombre.substring(0, 31).replace(/[/\\*?[\]:]/g, "");
|
||||
const dw = workbook.addWorksheet(safeName);
|
||||
dw.columns = [
|
||||
{ width: 3 },
|
||||
{ width: 35 }, { width: 5 }, { width: 15 },
|
||||
{ width: 5 },
|
||||
{ width: 35 }, { width: 5 }, { width: 15 },
|
||||
{ width: 5 }, { width: 18 },
|
||||
];
|
||||
dw.views = [{ showGridLines: false }];
|
||||
|
||||
dw.mergeCells("B1:J1");
|
||||
dw.getCell("B1").value = `${fases[serv.fase] || "FASE " + serv.fase} — ${serv.nombre}`;
|
||||
dw.getCell("B1").font = { bold: true, size: 14, name: "Calibri", color: { argb: WHITE } };
|
||||
dw.getCell("B1").alignment = { vertical: "middle" };
|
||||
fillRow(dw, 1, "A", "J", PRIMARY);
|
||||
dw.getRow(1).height = 30;
|
||||
|
||||
let r = 3;
|
||||
dw.getCell(`B${r}`).value = "Cliente:";
|
||||
dw.getCell(`B${r}`).font = labelFont;
|
||||
dw.getCell(`C${r}`).value = draft.clienteEmpresa || draft.clienteNombre;
|
||||
dw.getCell(`C${r}`).font = boldFont;
|
||||
dw.getCell(`F${r}`).value = "No. Cotizacion:";
|
||||
dw.getCell(`F${r}`).font = labelFont;
|
||||
dw.getCell(`G${r}`).value = "BORRADOR";
|
||||
dw.getCell(`G${r}`).font = boldFont;
|
||||
r += 2;
|
||||
|
||||
dw.getCell(`B${r}`).value = "Servicio:";
|
||||
dw.getCell(`B${r}`).font = labelFont;
|
||||
dw.getCell(`C${r}`).value = serv.nombre;
|
||||
dw.getCell(`C${r}`).font = { ...boldFont, size: 12, color: { argb: PRIMARY } };
|
||||
dw.getCell(`F${r}`).value = "Tiempo de Entrega:";
|
||||
dw.getCell(`F${r}`).font = labelFont;
|
||||
dw.getCell(`G${r}`).value = serv.tiempoEntrega;
|
||||
dw.getCell(`G${r}`).font = valueFont;
|
||||
r += 2;
|
||||
|
||||
const thCols = ["B", "I"];
|
||||
for (const c of thCols) {
|
||||
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
|
||||
dw.getCell(`${c}${r}`).font = headerFont;
|
||||
dw.getCell(`${c}${r}`).border = { bottom: { style: "thin", color: { argb: PRIMARY } } };
|
||||
}
|
||||
dw.getCell(`B${r}`).value = "Entregable";
|
||||
dw.getCell(`I${r}`).value = "";
|
||||
r++;
|
||||
|
||||
for (let i = 0; i < serv.entregables.length; i++) {
|
||||
const isAlt = i % 2 === 1;
|
||||
dw.getCell(`B${i + r}`).value = `${i + 1}. ${serv.entregables[i]}`;
|
||||
dw.getCell(`B${i + r}`).font = valueFont;
|
||||
const bg = isAlt ? LIGHT_BG : WHITE;
|
||||
dw.getCell(`B${i + r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: bg } };
|
||||
applyThinBorder(dw.getCell(`B${i + r}`), BORDER_COLOR);
|
||||
}
|
||||
r += serv.entregables.length + 1;
|
||||
|
||||
for (const c of ["B", "I"]) {
|
||||
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
|
||||
applyThinBorder(dw.getCell(`${c}${r}`), PRIMARY);
|
||||
}
|
||||
dw.getCell(`B${r}`).value = `Total Pago ${serv.tipoPago === "unico" ? "Unico" : "Mensual"}`;
|
||||
dw.getCell(`B${r}`).font = totalFont;
|
||||
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" };
|
||||
r++;
|
||||
dw.getCell(`B${r}`).value = "(+ IVA)";
|
||||
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
|
||||
r += 2;
|
||||
|
||||
dw.mergeCells(`B${r}:J${r}`);
|
||||
dw.getCell(`B${r}`).value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA.";
|
||||
dw.getCell(`B${r}`).font = smallFont;
|
||||
r += 2;
|
||||
|
||||
dw.getCell(`B${r}`).value = bancaria.razon_social || "Cotizador E3";
|
||||
dw.getCell(`B${r}`).font = { ...boldFont, color: { argb: PRIMARY } };
|
||||
if (bancaria.domicilio_fiscal) {
|
||||
r++;
|
||||
dw.mergeCells(`B${r}:J${r}`);
|
||||
dw.getCell(`B${r}`).value = bancaria.domicilio_fiscal;
|
||||
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return new NextResponse(buffer, {
|
||||
const buffer = await buildCotizacionExcel(data);
|
||||
return new NextResponse(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR.xlsx"`,
|
||||
|
||||
Reference in New Issue
Block a user