1
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user