This commit is contained in:
2026-06-10 02:04:37 -06:00
parent 1ba3d8dea4
commit 31cdce03a0
131 changed files with 15922 additions and 106 deletions
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/db";
import { createSession, setSessionCookie } from "@/lib/auth";
import { loginSchema } from "@/lib/schemas";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.password) {
return NextResponse.json(
{ error: "Credenciales invalidas" },
{ status: 401 }
);
}
const valid = await compare(password, user.password);
if (!valid) {
return NextResponse.json(
{ error: "Credenciales invalidas" },
{ status: 401 }
);
}
const token = await createSession({
userId: user.id,
email: user.email,
name: user.name,
role: user.role,
});
const response = NextResponse.json({
user: { id: user.id, name: user.name, email: user.email, role: user.role },
});
response.cookies.set(setSessionCookie(token));
return response;
} catch {
return NextResponse.json({ error: "Error interno" }, { status: 500 });
}
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "@/lib/auth";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(clearSessionCookie());
return response;
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
export async function GET() {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "No autenticado" }, { status: 401 });
}
return NextResponse.json({ user: session });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const servicio = await prisma.servicioCatalogo.update({
where: { id },
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
fase: body.fase,
tipoPago: body.tipoPago,
precioBase: body.precioBase,
tiempoEntrega: body.tiempoEntrega,
entregablesDefault: body.entregablesDefault || [],
variante: body.variante || null,
activo: body.activo !== false,
orden: body.orden,
categoriaId: body.categoriaId || null,
},
});
return NextResponse.json(servicio);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const inUse = await prisma.servicioCotizado.count({
where: { servicioCatalogoId: id },
});
if (inUse > 0) {
await prisma.servicioCatalogo.update({
where: { id },
data: { activo: false },
});
return NextResponse.json({ ok: true, archived: true });
}
await prisma.servicioCatalogo.delete({ where: { id } });
return NextResponse.json({ ok: true, archived: false });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { servicioCatalogoSchema } from "@/lib/schemas";
export async function GET() {
try {
const servicios = await prisma.servicioCatalogo.findMany({
orderBy: [{ fase: "asc" }, { orden: "asc" }],
});
return NextResponse.json(servicios);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = servicioCatalogoSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const servicio = await prisma.servicioCatalogo.create({
data: {
nombre: parsed.data.nombre,
descripcion: parsed.data.descripcion,
fase: parsed.data.fase,
tipoPago: parsed.data.tipoPago,
precioBase: parsed.data.precioBase,
tiempoEntrega: parsed.data.tiempoEntrega,
entregablesDefault: parsed.data.entregablesDefault,
categoriaId: parsed.data.categoriaId,
variante: parsed.data.variante,
activo: true,
orden: parsed.data.orden,
},
});
return NextResponse.json(servicio, { status: 201 });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const cat = await prisma.categoria.update({
where: { id },
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
color: body.color,
orden: body.orden,
activo: body.activo !== false,
},
});
return NextResponse.json(cat);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const inUse = await prisma.servicioCatalogo.count({ where: { categoriaId: id } });
if (inUse > 0) {
return NextResponse.json(
{ error: `Tiene ${inUse} servicios asociados. Desactiva la categoria en su lugar.` },
{ status: 409 }
);
}
await prisma.categoria.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const cats = await prisma.categoria.findMany({ orderBy: { orden: "asc" } });
return NextResponse.json(cats);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const cat = await prisma.categoria.create({
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
color: body.color || "#6b7280",
orden: body.orden ?? 0,
},
});
return NextResponse.json(cat, { status: 201 });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const configs = await prisma.configuracion.findMany();
const map: Record<string, string> = {};
for (const c of configs) {
map[c.clave] = c.valor;
}
return NextResponse.json(map);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = (await request.json()) as Record<string, string>;
const allowedKeys = [
"color_primario",
"color_secundario",
"logo_base64",
"razon_social",
"rfc",
"domicilio_fiscal",
"cuenta_nacional",
"clabe_interbancaria",
"cuenta_internacional",
"cuenta_internacional_swift",
"hora_centinela",
"anualidad_hosting",
"iva",
"terminos_condiciones",
"no_incluye",
"notas_adicionales",
];
const ops = Object.entries(body)
.filter(([clave]) => allowedKeys.includes(clave))
.map(([clave, valor]) =>
prisma.configuracion.upsert({
where: { clave },
update: { valor },
create: { clave, valor },
})
);
await prisma.$transaction(ops);
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { z } from "zod";
const schema = z.object({
servicioId: z.string().min(1),
precio: z.number().min(0),
});
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const { servicioId, precio } = parsed.data;
const serv = await prisma.servicioCotizado.findFirst({
where: { id: servicioId, cotizacionId: id },
});
if (!serv) {
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
}
await prisma.servicioCotizado.update({
where: { id: servicioId },
data: { precio },
});
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+189
View File
@@ -0,0 +1,189 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { ESTADOS_COTIZACION } from "@/lib/calculators";
import { cotizacionPutSchema } from "@/lib/schemas";
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({ where: { id } });
if (!cot) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
await prisma.$transaction([
prisma.cotizacion.delete({ where: { id } }),
]);
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
});
if (!cot) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
return NextResponse.json(cot);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const parsed = cotizacionPutSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const {
fecha,
vigencia,
moneda,
tipoCambio,
proyecto,
esquemaPago,
incluirBonos,
incluirFinanciamiento,
observaciones,
cliente,
servicios,
planBucefalo,
} = parsed.data;
const estado = body.estado;
const existing = await prisma.cotizacion.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
if (cliente) {
const existingCliente = await prisma.cliente.findFirst({
where: { nombre: cliente.nombre, empresa: cliente.empresa || null },
});
let clienteId = existing.clienteId;
if (existingCliente) {
clienteId = existingCliente.id;
await prisma.cliente.update({
where: { id: clienteId },
data: {
email: cliente.email || existingCliente.email,
telefono: cliente.telefono || existingCliente.telefono,
},
});
} else {
const newCliente = await prisma.cliente.create({
data: {
nombre: cliente.nombre,
empresa: cliente.empresa || null,
email: cliente.email || null,
telefono: cliente.telefono || null,
},
});
clienteId = newCliente.id;
}
await prisma.cotizacion.update({
where: { id },
data: { clienteId },
});
}
await prisma.cotizacion.update({
where: { id },
data: {
...(fecha && { fecha: new Date(fecha) }),
...(vigencia && { vigencia: new Date(vigencia) }),
...(moneda && { moneda }),
...(tipoCambio !== undefined && { tipoCambio }),
...(proyecto && { proyecto }),
...(esquemaPago && { esquemaPago }),
...(incluirBonos !== undefined && { incluirBonos }),
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
...(observaciones !== undefined && { observaciones }),
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
},
});
if (servicios && Array.isArray(servicios)) {
await prisma.$transaction(async (tx) => {
await tx.servicioCotizado.deleteMany({ where: { cotizacionId: id } });
for (const serv of servicios) {
const catalogoId = serv.catalogoId?.startsWith("bucefalo-")
? (await tx.servicioCatalogo.findFirst({
where: { categoriaRel: { nombre: "CRM" }, tipoPago: "mensual" },
}))?.id || serv.catalogoId
: serv.catalogoId;
await tx.servicioCotizado.create({
data: {
cotizacionId: id,
servicioCatalogoId: catalogoId,
fase: serv.fase,
tipoPago: serv.tipoPago,
precio: serv.precio,
tiempoEntrega: serv.tiempoEntrega,
entregables: serv.entregables,
seleccionado: true,
},
});
}
});
}
if (planBucefalo) {
await prisma.planBucefaloCotizacion.upsert({
where: { cotizacionId: id },
update: { nivel: planBucefalo.nivel, precio: planBucefalo.precio },
create: {
cotizacionId: id,
nivel: planBucefalo.nivel,
precio: planBucefalo.precio,
},
});
} else {
await prisma.planBucefaloCotizacion.deleteMany({ where: { cotizacionId: id } });
}
const updated = await prisma.cotizacion.findUnique({
where: { id },
include: { cliente: true, asesor: true, servicios: true },
});
return NextResponse.json(updated);
} catch (error: unknown) {
console.error("Error updating cotizacion:", error);
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { cotizacionPostSchema } from "@/lib/schemas";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = cotizacionPostSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const {
numero,
fecha,
vigencia,
moneda,
tipoCambio,
proyecto,
esquemaPago,
incluirBonos,
incluirFinanciamiento,
observaciones,
cliente,
asesorId,
servicios,
planBucefalo,
} = parsed.data;
let clienteRecord = await prisma.cliente.findFirst({
where: {
nombre: cliente.nombre,
empresa: cliente.empresa || null,
},
});
if (!clienteRecord) {
clienteRecord = await prisma.cliente.create({
data: {
nombre: cliente.nombre,
empresa: cliente.empresa || null,
email: cliente.email || null,
telefono: cliente.telefono || null,
},
});
}
const clienteIdFinal = clienteRecord.id;
const cotizacion = await prisma.$transaction(async (tx) => {
const cot = await tx.cotizacion.create({
data: {
numero,
fecha: new Date(fecha),
vigencia: new Date(vigencia),
moneda,
tipoCambio,
proyecto,
esquemaPago,
incluirBonos,
incluirFinanciamiento,
observaciones: observaciones || null,
clienteId: clienteIdFinal,
asesorId,
estado: "borrador",
},
});
for (const servicio of servicios) {
const catalogoId = servicio.catalogoId.startsWith("bucefalo-")
? (await tx.servicioCatalogo.findFirst({
where: { categoriaRel: { nombre: "CRM" }, tipoPago: "mensual" },
}))?.id || servicio.catalogoId
: servicio.catalogoId;
await tx.servicioCotizado.create({
data: {
cotizacionId: cot.id,
servicioCatalogoId: catalogoId,
fase: servicio.fase,
tipoPago: servicio.tipoPago,
precio: servicio.precio,
tiempoEntrega: servicio.tiempoEntrega,
entregables: servicio.entregables,
seleccionado: true,
},
});
}
if (planBucefalo) {
await tx.planBucefaloCotizacion.create({
data: {
cotizacionId: cot.id,
nivel: planBucefalo.nivel,
precio: planBucefalo.precio,
seleccionado: true,
},
});
}
return cot;
});
return NextResponse.json(cotizacion, { status: 201 });
} catch (error: unknown) {
console.error("Error creating cotizacion:", error);
const message = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET() {
try {
const cotizaciones = await prisma.cotizacion.findMany({
orderBy: { createdAt: "desc" },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
});
return NextResponse.json(cotizaciones);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const categorias = await prisma.categoria.findMany({
where: { activo: true },
orderBy: { orden: "asc" },
select: { nombre: true },
});
const catList = categorias.map((c) => c.nombre).join(" / ");
const csv = `Nombre,Categoria,Fase,Tipo de Pago,Precio Base,Tiempo de Entrega,Entregables,Variante
"SEO On-Page Basico","SEO","Setup e Infraestructura","unico",5000,"7 - 14 dias","Auditoria SEO | Optimizacion de meta tags | Mapa del sitio",
"Manejo de Redes Sociales","Marketing","Publicidad y Manejo","mensual",8000,"Mensual","Publicaciones semanales | Reporte mensual | Gestion de comunidad","Basico"
"Gestion de Google Ads","Paid Media","Publicidad y Manejo","mensual",5000,"Mensual","Configuracion de campanas | Reporte semanal",
"Sitio Web Informativo","Desarrollo Web","Setup e Infraestructura","unico",15000,"15 - 30 dias","Diseno responsivo | Hasta 5 secciones | Formulario de contacto",
"Automatizacion Email Marketing","Automatizaciones","Publicidad y Manejo","unico",8000,"10 - 15 dias","Configuracion de flujos | Plantillas de correo | Integracion CRM",
"CRM Basico","CRM","Publicidad y Manejo","mensual",1000,"Mensual","CRM completo segun plan contratado","basico"
,,,,
"CATEGORIAS DISPONIBLES: ${catList}",,,,,
"FASES: Auditoria / Setup e Infraestructura / Publicidad y Manejo / Contenido y SEO",,,,,
"TIPOS DE PAGO: unico / mensual",,,,,
"ENTREGABLES: separar con | (pipe)",,,,,
"VARIANTE: opcional (ej: Basico, Estandar, Premium)",,,,,
`;
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": "attachment; filename=plantilla-catalogo.csv",
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const servicios = await prisma.servicioCatalogo.findMany({
where: { activo: true },
orderBy: [{ fase: "asc" }, { orden: "asc" }],
include: { categoriaRel: true },
});
const FASES: Record<number, string> = {
0: "Auditoria",
1: "Setup e Infraestructura",
2: "Publicidad y Manejo",
3: "Contenido y SEO",
};
const header = "Nombre,Categoria,Fase,Tipo de Pago,Precio Base,Tiempo de Entrega,Entregables,Variante\n";
const rows = servicios.map((s) =>
[
csvEscape(s.nombre),
csvEscape(s.categoriaRel?.nombre || ""),
csvEscape(FASES[s.fase] || `Fase ${s.fase}`),
csvEscape(s.tipoPago === "unico" ? "Pago Unico" : "Mensual"),
s.precioBase.toString(),
csvEscape(s.tiempoEntrega),
csvEscape((s.entregablesDefault as string[]).join(" | ")),
csvEscape(s.variante || ""),
].join(",")
).join("\n");
return new NextResponse(header + rows, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": "attachment; filename=catalogo-servicios.csv",
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
function csvEscape(val: string): string {
if (val.includes(",") || val.includes('"') || val.includes("\n")) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
}
+359
View File
@@ -0,0 +1,359 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import ExcelJS from "exceljs";
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators";
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 } };
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
});
if (!cot) {
return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
}
const empresa = cot.cliente.empresa || cot.cliente.nombre;
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 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 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 }];
// ── HEADER BANNER ──
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 = cot.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);
// ── CLIENTE / PROYECTO ──
const infoStart = 5;
const infoLabels: [string, string, string, string][] = [
["B", "En atencion a:", "C", cot.cliente.nombre],
["E", "Empresa:", "F", cot.cliente.empresa || "—"],
["B", "Proyecto:", "C", cot.proyecto],
["E", "Moneda:", "F", cot.moneda],
["B", "Asesor:", "C", cot.asesor.name],
["E", "Tipo de Cambio:", "F", cot.tipoCambio],
["B", "Fecha:", "C", ""],
["E", "Esquema de Pago:", "F", cot.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 = cot.fecha;
fechaCell.numFmt = "DD/MM/YYYY";
fechaCell.font = valueFont;
const vigenciaCell = ws.getCell(`C${infoStart + 7}`);
ws.getCell(`B${infoStart + 7}`).value = "Vigencia:";
ws.getCell(`B${infoStart + 7}`).font = labelFont;
vigenciaCell.value = cot.vigencia;
vigenciaCell.numFmt = "DD/MM/YYYY";
vigenciaCell.font = valueFont;
// ── TABLA SERVICIOS ──
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 = cot.servicios.filter(s => s.tipoPago === "unico" && s.seleccionado);
const serviciosMensuales = cot.servicios.filter(s => s.tipoPago === "mensual" && s.seleccionado);
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.servicioCatalogo?.nombre || "Servicio";
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++;
}
// ── TOTALES ──
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 (cot.planBucefalo) {
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = `CRM Bucefalo - ${cot.planBucefalo.nivel.charAt(0).toUpperCase() + cot.planBucefalo.nivel.slice(1)}`;
ws.getCell(`B${row}`).font = boldFont;
ws.getCell(`K${row}`).value = cot.planBucefalo.precio;
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;
}
// ── NOTA AL PIE ──
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;
// ── HOJAS DETALLADAS POR SERVICIO ──
for (const serv of cot.servicios.filter(s => s.seleccionado)) {
const nombre = serv.servicioCatalogo?.nombre || "Servicio";
const safeName = 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 }];
// Banner
dw.mergeCells("B1:J1");
dw.getCell("B1").value = `${fases[serv.fase] || "FASE " + serv.fase}${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;
// Info
let r = 3;
dw.getCell(`B${r}`).value = "Cliente:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = cot.cliente.empresa || cot.cliente.nombre;
dw.getCell(`C${r}`).font = boldFont;
dw.getCell(`F${r}`).value = "No. Cotizacion:";
dw.getCell(`F${r}`).font = labelFont;
dw.getCell(`G${r}`).value = cot.numero;
dw.getCell(`G${r}`).font = boldFont;
r += 2;
// Servicio header
dw.getCell(`B${r}`).value = "Servicio:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = 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;
// Tabla entregables
const tableCols = ["B", "C"];
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++;
const entregables = Array.isArray(serv.entregables) ? serv.entregables as string[] : [];
for (let i = 0; i < entregables.length; i++) {
const isAlt = i % 2 === 1;
dw.getCell(`B${i + r}`).value = `${i + 1}. ${entregables[i]}`;
dw.getCell(`B${i + r}`).font = valueFont;
const bg = isAlt ? LIGHT_BG : WHITE;
for (const c of tableCols) {
dw.getCell(`${c}${i + r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: bg } };
}
applyThinBorder(dw.getCell(`B${i + r}`), BORDER_COLOR);
}
r += entregables.length + 1;
// Precio
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;
// Footer
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, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(cot.cliente.nombre)} - ${cot.numero}.xlsx"`,
},
});
} catch (error: unknown) {
console.error("Error exporting Excel:", error);
const message = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+350
View File
@@ -0,0 +1,350 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { calcularVigencia, bucefaloPrecio, 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 } };
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { draft } = body as {
draft: {
clienteNombre: string;
clienteEmpresa: string;
asesorNombre: string;
fecha: string;
moneda: string;
tipoCambio: string;
proyecto: string;
esquemaPago: string;
incluirBonos: boolean;
servicios: {
nombre: string;
fase: number;
tipoPago: string;
precio: number;
tiempoEntrega: string;
entregables: string[];
}[];
planBucefaloNivel: string | null;
observaciones: 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 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 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, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR.xlsx"`,
},
});
} catch (error: unknown) {
console.error("Error exporting Excel:", error);
const message = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { generateCotizacionPDF } from "@/lib/pdf-generator";
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
});
if (!cot) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
const config = await getConfigBancaria();
const branding = await getConfigBranding();
const empresa = cot.cliente.empresa || cot.cliente.nombre;
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(cot.cliente.nombre)} - ${cot.numero}`;
const buffer = await generateCotizacionPDF({
numero: cot.numero,
clienteNombre: cot.cliente.nombre,
clienteEmpresa: cot.cliente.empresa || "",
asesorNombre: cot.asesor.name,
fecha: cot.fecha,
vigencia: cot.vigencia,
moneda: cot.moneda,
tipoCambio: cot.tipoCambio,
proyecto: cot.proyecto,
esquemaPago: cot.esquemaPago,
servicios: cot.servicios.filter((s) => s.seleccionado).map((s) => ({
nombre: s.servicioCatalogo?.nombre || "Servicio",
fase: s.fase,
tipoPago: s.tipoPago,
precio: s.precio,
tiempoEntrega: s.tiempoEntrega,
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
})),
planBucefaloNivel: cot.planBucefalo?.nivel || null,
planBucefaloPrecio: cot.planBucefalo?.precio || 0,
incluirBonos: cot.incluirBonos,
configBancaria: config,
...branding,
});
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { generateCotizacionPDF } from "@/lib/pdf-generator";
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
import { getConfigBranding } from "@/lib/config-helpers";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { draft } = body as {
draft: {
clienteNombre: string;
clienteEmpresa: string;
asesorNombre: string;
fecha: string;
moneda: string;
tipoCambio: string;
proyecto: string;
esquemaPago: string;
incluirBonos: boolean;
servicios: {
nombre: string;
fase: number;
tipoPago: string;
precio: number;
tiempoEntrega: string;
entregables: string[];
}[];
planBucefaloNivel: string | null;
observaciones: string;
};
};
const fechaCot = new Date(draft.fecha);
const vigencia = calcularVigencia(fechaCot);
const branding = await getConfigBranding();
const empresa = draft.clienteEmpresa || draft.clienteNombre;
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`;
const buffer = await generateCotizacionPDF({
numero: "BORRADOR",
clienteNombre: draft.clienteNombre,
clienteEmpresa: draft.clienteEmpresa,
asesorNombre: draft.asesorNombre,
fecha: fechaCot,
vigencia,
moneda: draft.moneda,
tipoCambio: draft.tipoCambio,
proyecto: draft.proyecto,
esquemaPago: draft.esquemaPago,
servicios: draft.servicios,
planBucefaloNivel: draft.planBucefaloNivel,
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
incluirBonos: draft.incluirBonos,
...branding,
});
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${nombre}.pdf"`,
},
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+171
View File
@@ -0,0 +1,171 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
interface ParsedRow {
nombre: string;
categoria: string;
fase: number;
tipoPago: string;
precioBase: number;
tiempoEntrega: string;
entregables: string[];
variante: string | null;
}
const FASES_MAP: Record<string, number> = {
auditoria: 0,
"setup e infraestructura": 1,
setup: 1,
infraestructura: 1,
"publicidad y manejo": 2,
publicidad: 2,
manejo: 2,
"contenido y seo": 3,
contenido: 3,
seo: 3,
};
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
current += '"';
i++;
} else if (ch === '"') {
inQuotes = false;
} else {
current += ch;
}
} else {
if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
fields.push(current.trim());
current = "";
} else {
current += ch;
}
}
}
fields.push(current.trim());
return fields;
}
function parseRow(fields: string[]): ParsedRow | null {
const [nombre, categoria, faseStr, tipoPago, precioStr, tiempoEntrega, entregablesStr, variante] = fields;
if (!nombre || !categoria || !tipoPago || !precioStr) return null;
const faseKey = faseStr.toLowerCase().trim();
const fase = FASES_MAP[faseKey];
if (fase === undefined) return null;
const tipo = tipoPago.toLowerCase().trim();
if (tipo !== "unico" && tipo !== "mensual") return null;
const precioBase = parseFloat(precioStr);
if (isNaN(precioBase) || precioBase < 0) return null;
return {
nombre,
categoria,
fase,
tipoPago: tipo,
precioBase,
tiempoEntrega: tiempoEntrega || "7 - 14 dias",
entregables: entregablesStr
? entregablesStr.split("|").map((e) => e.trim()).filter(Boolean)
: [],
variante: variante || null,
};
}
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No se recibio archivo" }, { status: 400 });
}
const text = await file.text();
const lines = text
.split(/\r?\n/)
.filter((l) => l.trim() && !l.trim().startsWith('"CATEGORIAS') && !l.trim().startsWith('"FASES') && !l.trim().startsWith('"TIPOS') && !l.trim().startsWith('"ENTREGABLES') && !l.trim().startsWith('"VARIANTE'));
if (lines.length < 2) {
return NextResponse.json({ error: "El CSV esta vacio o no tiene datos" }, { status: 400 });
}
const categorias = await prisma.categoria.findMany({
where: { activo: true },
select: { id: true, nombre: true },
});
const catMap = new Map(categorias.map((c) => [c.nombre.toLowerCase(), c.id]));
const rows: ParsedRow[] = [];
for (let i = 1; i < lines.length; i++) {
const fields = parseCsvLine(lines[i]);
if (fields.length < 5) continue;
const row = parseRow(fields);
if (row) rows.push(row);
}
if (rows.length === 0) {
return NextResponse.json(
{ error: "No se encontraron filas validas. Verifica el formato del CSV." },
{ status: 400 }
);
}
let creados = 0;
let omitidos = 0;
const errores: string[] = [];
const maxOrden = await prisma.servicioCatalogo.aggregate({ _max: { orden: true } });
let nextOrden = (maxOrden._max.orden || 0) + 1;
const toCreate: { nombre: string; fase: number; tipoPago: string; precioBase: number; tiempoEntrega: string; entregablesDefault: string[]; variante: string | null; categoriaId: string; orden: number; activo: boolean }[] = [];
for (const row of rows) {
const categoriaId = catMap.get(row.categoria.toLowerCase());
if (!categoriaId) {
errores.push(`"${row.nombre}": categoria "${row.categoria}" no encontrada`);
omitidos++;
continue;
}
toCreate.push({
nombre: row.nombre,
fase: row.fase,
tipoPago: row.tipoPago,
precioBase: row.precioBase,
tiempoEntrega: row.tiempoEntrega,
entregablesDefault: row.entregables,
variante: row.variante,
categoriaId,
orden: nextOrden++,
activo: true,
});
}
if (toCreate.length > 0) {
await prisma.servicioCatalogo.createMany({ data: toCreate });
}
creados = toCreate.length;
return NextResponse.json({
creados,
omitidos,
errores: errores.length > 0 ? errores : undefined,
});
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id: paqueteId } = await params;
const body = await request.json();
if (body.action === "addFase") {
const fase = await prisma.fasePaquete.create({
data: {
paqueteId,
nombre: body.nombre,
orden: body.orden ?? 0,
},
});
return NextResponse.json(fase, { status: 201 });
}
if (body.action === "updateFase") {
const fase = await prisma.fasePaquete.update({
where: { id: body.faseId },
data: { nombre: body.nombre, orden: body.orden },
});
return NextResponse.json(fase);
}
if (body.action === "deleteFase") {
await prisma.fasePaquete.delete({ where: { id: body.faseId } });
return NextResponse.json({ ok: true });
}
if (body.action === "addServicio") {
const link = await prisma.servicioPaquete.create({
data: {
servicioCatalogoId: body.servicioCatalogoId,
fasePaqueteId: body.fasePaqueteId,
},
});
return NextResponse.json(link, { status: 201 });
}
if (body.action === "removeServicio") {
await prisma.servicioPaquete.deleteMany({
where: {
servicioCatalogoId: body.servicioCatalogoId,
fasePaqueteId: body.fasePaqueteId,
},
});
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Accion desconocida" }, { status: 400 });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const paquete = await prisma.paquete.findUnique({
where: { id },
include: {
fases: {
orderBy: { orden: "asc" },
include: {
servicios: {
include: { servicio: { include: { categoriaRel: true } } },
},
},
},
},
});
if (!paquete) return NextResponse.json({ error: "No encontrado" }, { status: 404 });
return NextResponse.json(paquete);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const paquete = await prisma.paquete.update({
where: { id },
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
activo: body.activo !== false,
},
});
return NextResponse.json(paquete);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.paquete.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const paquetes = await prisma.paquete.findMany({
where: { activo: true },
include: {
fases: {
orderBy: { orden: "asc" },
include: {
servicios: {
include: { servicio: { include: { categoriaRel: true } } },
orderBy: { servicio: { orden: "asc" } },
},
},
},
},
orderBy: { createdAt: "asc" },
});
return NextResponse.json(paquetes);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const paquete = await prisma.paquete.create({
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
fases: {
create: (body.fases || []).map((f: { nombre: string; orden: number }, i: number) => ({
nombre: f.nombre,
orden: f.orden ?? i,
})),
},
},
include: { fases: true },
});
return NextResponse.json(paquete, { status: 201 });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}