1
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || (() => { throw new Error("JWT_SECRET env var is required"); })()
|
||||
);
|
||||
|
||||
const COOKIE_NAME = "cotizador-session";
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export async function createSession(payload: SessionPayload): Promise<string> {
|
||||
const token = await new SignJWT(payload as unknown as Record<string, unknown>)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("7d")
|
||||
.setIssuedAt()
|
||||
.sign(JWT_SECRET);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function verifySession(
|
||||
token: string
|
||||
): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return payload as unknown as SessionPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<SessionPayload | null> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
return verifySession(token);
|
||||
}
|
||||
|
||||
export function setSessionCookie(token: string) {
|
||||
return {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSessionCookie() {
|
||||
return {
|
||||
name: COOKIE_NAME,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export const IVA_RATE = 0.16;
|
||||
|
||||
export const FASES: Record<number, string> = {
|
||||
0: "FASE 0 - Auditoria / Acompanamiento",
|
||||
1: "FASE 1 - Setup e Infraestructura",
|
||||
2: "FASE 2 - Publicidad y Manejo",
|
||||
3: "FASE 3 - Contenido y SEO",
|
||||
};
|
||||
|
||||
export const FASES_SHORT: Record<number, string> = {
|
||||
0: "FASE 0 - Auditoria",
|
||||
1: "FASE 1 - Setup e Infraestructura",
|
||||
2: "FASE 2 - Publicidad y Manejo",
|
||||
3: "FASE 3 - Contenido y SEO",
|
||||
};
|
||||
|
||||
export const PLANES_BUCEFALO = [
|
||||
{ nivel: "basico", label: "Basico", precio: 1000 },
|
||||
{ nivel: "estandar", label: "Estandar", precio: 3500 },
|
||||
{ nivel: "premium", label: "Premium", precio: 4500 },
|
||||
{ nivel: "empresarial", label: "Empresarial", precio: 7500 },
|
||||
] as const;
|
||||
|
||||
export const ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] as const;
|
||||
export type EstadoCotizacion = (typeof ESTADOS_COTIZACION)[number];
|
||||
|
||||
export function bucefaloPrecio(nivel: string): number {
|
||||
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
|
||||
}
|
||||
|
||||
export function calcularVigencia(fecha: Date): Date {
|
||||
const vigencia = new Date(fecha);
|
||||
let diasHabiles = 0;
|
||||
while (diasHabiles < 15) {
|
||||
vigencia.setDate(vigencia.getDate() + 1);
|
||||
const dia = vigencia.getDay();
|
||||
if (dia !== 0 && dia !== 6) diasHabiles++;
|
||||
}
|
||||
return vigencia;
|
||||
}
|
||||
|
||||
export function calcularFinanciamiento(input: { monto: number; meses: number; tasa: number; comision: number; iva: number }) {
|
||||
const { monto, meses, tasa, comision, iva } = input;
|
||||
const comisionTotal = (monto * comision) / 100;
|
||||
const montoConComision = monto + comisionTotal;
|
||||
const pagoMensual = montoConComision * (1 + tasa) / meses;
|
||||
const ivaMensual = pagoMensual * iva;
|
||||
const totalMensual = pagoMensual + ivaMensual;
|
||||
return {
|
||||
pagoMensual: Math.round(pagoMensual * 100) / 100,
|
||||
ivaMensual: Math.round(ivaMensual * 100) / 100,
|
||||
totalMensual: Math.round(totalMensual * 100) / 100,
|
||||
comisionTotal: Math.round(comisionTotal * 100) / 100,
|
||||
granTotal: Math.round(totalMensual * meses * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
export function generarNumeroCotizacion(asesorIniciales: string, secuencia: number): string {
|
||||
const now = new Date();
|
||||
const yy = now.getFullYear().toString().slice(-2);
|
||||
const mm = (now.getMonth() + 1).toString().padStart(2, "0");
|
||||
const seq = secuencia.toString().padStart(3, "0");
|
||||
return `UJ${yy}${mm}${asesorIniciales}${seq}`;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^\w\s.\-]/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency: "MXN",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { prisma } from "./db";
|
||||
|
||||
export async function getConfigBranding() {
|
||||
try {
|
||||
const configs = await prisma.configuracion.findMany({
|
||||
where: { clave: { in: ["color_primario", "color_secundario", "logo_base64"] } },
|
||||
});
|
||||
const map: Record<string, string> = {};
|
||||
for (const c of configs) map[c.clave] = c.valor;
|
||||
return {
|
||||
colorPrimario: map.color_primario || undefined,
|
||||
colorSecundario: map.color_secundario || undefined,
|
||||
logoBase64: map.logo_base64 ? (map.logo_base64.includes(":") ? map.logo_base64.split(":").slice(1).join(":") : map.logo_base64) : undefined,
|
||||
logoMime: map.logo_base64 ? (map.logo_base64.includes(":") ? map.logo_base64.split(":")[0] : "image/png") : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConfigBancaria() {
|
||||
try {
|
||||
const configs = await prisma.configuracion.findMany({
|
||||
where: {
|
||||
clave: {
|
||||
in: [
|
||||
"razon_social",
|
||||
"rfc",
|
||||
"domicilio_fiscal",
|
||||
"cuenta_nacional",
|
||||
"clabe_interbancaria",
|
||||
"cuenta_internacional",
|
||||
"cuenta_internacional_swift",
|
||||
"hora_centinela",
|
||||
"terminos_condiciones",
|
||||
"no_incluye",
|
||||
"notas_adicionales",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const map: Record<string, string> = {};
|
||||
for (const c of configs) map[c.clave] = c.valor;
|
||||
return map;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
function createPrismaClient() {
|
||||
const adapter = new PrismaPg({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "postgres",
|
||||
database: process.env.DB_NAME || "cotizador_e3",
|
||||
});
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
@@ -0,0 +1,406 @@
|
||||
import PDFDocument from "pdfkit";
|
||||
import { FASES_SHORT as FASES } from "./calculators";
|
||||
|
||||
interface ServicioPDF {
|
||||
nombre: string;
|
||||
fase: number;
|
||||
tipoPago: string;
|
||||
precio: number;
|
||||
tiempoEntrega: string;
|
||||
entregables: string[];
|
||||
}
|
||||
|
||||
interface CotizacionPDFData {
|
||||
numero: string;
|
||||
clienteNombre: string;
|
||||
clienteEmpresa: string;
|
||||
asesorNombre: string;
|
||||
fecha: Date;
|
||||
vigencia: Date;
|
||||
moneda: string;
|
||||
tipoCambio: string;
|
||||
proyecto: string;
|
||||
esquemaPago: string;
|
||||
servicios: ServicioPDF[];
|
||||
planBucefaloNivel: string | null;
|
||||
planBucefaloPrecio: number;
|
||||
incluirBonos: boolean;
|
||||
configBancaria?: Record<string, string>;
|
||||
colorPrimario?: string;
|
||||
colorSecundario?: string;
|
||||
logoBase64?: string;
|
||||
logoMime?: string;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency: "MXN",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return new Intl.DateTimeFormat("es-MX", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
async function toPngBuffer(base64: string, mime: string): Promise<Buffer> {
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
if (mime === "image/svg+xml") {
|
||||
const sharp = (await import("sharp")).default;
|
||||
return sharp(buf).png().toBuffer();
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Buffer> {
|
||||
const logoBuffer = data.logoBase64 && data.logoMime
|
||||
? await toPngBuffer(data.logoBase64, data.logoMime).catch(() => undefined)
|
||||
: data.logoBase64
|
||||
? Buffer.from(data.logoBase64, "base64")
|
||||
: undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const PRIMARY = data.colorPrimario || "#2563eb";
|
||||
const DARK = data.colorSecundario || "#1e293b";
|
||||
const MUTED = "#64748b";
|
||||
const BORDER = "#cbd5e1";
|
||||
const LIGHT_BG = "#f1f5f9";
|
||||
const WHITE = "#ffffff";
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
margins: { top: 45, bottom: 55, left: 50, right: 50 },
|
||||
bufferPages: true,
|
||||
});
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
doc.on("error", reject);
|
||||
|
||||
const pw = doc.page.width;
|
||||
const ph = doc.page.height;
|
||||
const m = doc.page.margins;
|
||||
const W = pw - m.left - m.right;
|
||||
const L = m.left;
|
||||
const maxY = ph - m.bottom - 5;
|
||||
|
||||
function need(h: number, y: number): number {
|
||||
if (y + h > maxY) {
|
||||
doc.addPage();
|
||||
return m.top;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
function rowBg(y: number, h: number, bg: string) {
|
||||
doc.save();
|
||||
doc.rect(L, y, W, h).fill(bg);
|
||||
doc.moveTo(L, y + h).lineTo(L + W, y + h).strokeColor(BORDER).lineWidth(0.3).stroke();
|
||||
doc.restore();
|
||||
}
|
||||
|
||||
function txtHeight(str: string, width: number, size: number): number {
|
||||
return doc.font("Helvetica").fontSize(size).heightOfString(str, { width });
|
||||
}
|
||||
|
||||
// ── PAGE 1: COVER ──────────────────────────────
|
||||
doc.rect(0, 0, pw, ph).fill(WHITE);
|
||||
|
||||
if (logoBuffer) {
|
||||
try { doc.image(logoBuffer, L, 55, { height: 65 }); } catch {}
|
||||
} else {
|
||||
doc.font("Helvetica-Bold").fontSize(28).fillColor(PRIMARY).text("UJ", L, 55);
|
||||
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text("Uriel Jareth Consulting", L + 48, 70);
|
||||
}
|
||||
|
||||
const barY = logoBuffer ? 145 : 130;
|
||||
doc.rect(L, barY, W, 4).fill(PRIMARY);
|
||||
|
||||
doc.font("Helvetica-Bold").fontSize(36).fillColor(DARK).text("Cotizacion", L, barY + 24);
|
||||
doc.font("Helvetica").fontSize(18).fillColor(PRIMARY).text(data.numero, L, barY + 68);
|
||||
|
||||
let iy = barY + 110;
|
||||
const cL = L;
|
||||
const cR = L + W * 0.52;
|
||||
|
||||
const infoL: [string, string][] = [
|
||||
["Cliente", data.clienteNombre],
|
||||
...(data.clienteEmpresa ? [["Empresa", data.clienteEmpresa]] as [string, string][] : []),
|
||||
["Proyecto", data.proyecto],
|
||||
["Asesor", data.asesorNombre],
|
||||
];
|
||||
const infoR: [string, string][] = [
|
||||
["Fecha", fmtDate(data.fecha)],
|
||||
["Vigencia", fmtDate(data.vigencia)],
|
||||
["Moneda", data.moneda],
|
||||
["Esquema", data.esquemaPago],
|
||||
];
|
||||
|
||||
for (let i = 0; i < Math.max(infoL.length, infoR.length); i++) {
|
||||
if (i < infoL.length) {
|
||||
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(infoL[i][0], cL, iy);
|
||||
doc.font("Helvetica").fontSize(11).fillColor(DARK).text(infoL[i][1], cL, iy + 12);
|
||||
}
|
||||
if (i < infoR.length) {
|
||||
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(infoR[i][0], cR, iy);
|
||||
doc.font("Helvetica").fontSize(11).fillColor(DARK).text(infoR[i][1], cR, iy + 12);
|
||||
}
|
||||
iy += 32;
|
||||
}
|
||||
|
||||
// ── PAGE 2+: RESUMEN ──────────────────────────
|
||||
doc.addPage();
|
||||
let y = m.top;
|
||||
|
||||
if (logoBuffer) {
|
||||
try { doc.image(logoBuffer, L + W - 70, m.top - 5, { height: 22 }); } catch {}
|
||||
}
|
||||
|
||||
doc.rect(L, y, 4, 16).fill(PRIMARY);
|
||||
doc.font("Helvetica-Bold").fontSize(15).fillColor(DARK).text("Hoja Resumen", L + 12, y + 1);
|
||||
y += 26;
|
||||
|
||||
doc.font("Helvetica").fontSize(8).fillColor(MUTED);
|
||||
doc.text(`En atencion a: ${data.clienteEmpresa || data.clienteNombre}`, L, y);
|
||||
doc.text(`No. Cotizacion: ${data.numero}`, L + W * 0.45, y);
|
||||
y += 11;
|
||||
doc.text(`Asesor: ${data.asesorNombre}`, L, y);
|
||||
doc.text(`Fecha: ${fmtDate(data.fecha)} | Moneda: ${data.moneda}`, L + W * 0.45, y);
|
||||
y += 16;
|
||||
|
||||
const colNombre = L;
|
||||
const colTipo = L + W - 200;
|
||||
const colTiempo = L + W - 120;
|
||||
const colPrecio = L + W - 60;
|
||||
|
||||
rowBg(y, 16, LIGHT_BG);
|
||||
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK);
|
||||
doc.text("Servicio", colNombre + 6, y + 4);
|
||||
doc.text("Tipo", colTipo + 4, y + 4);
|
||||
doc.text("Entrega", colTiempo + 4, y + 4);
|
||||
doc.text("Precio", colPrecio, y + 4, { width: 60, align: "right" });
|
||||
y += 18;
|
||||
|
||||
const unicos = data.servicios.filter((s) => s.tipoPago === "unico");
|
||||
const mensuales = data.servicios.filter((s) => s.tipoPago === "mensual");
|
||||
|
||||
function drawSection(servicios: ServicioPDF[], tipoLabel: string, tituloTotal: string) {
|
||||
if (servicios.length === 0) return y;
|
||||
let currentFase = -1;
|
||||
|
||||
for (const serv of servicios) {
|
||||
if (serv.fase !== currentFase) {
|
||||
currentFase = serv.fase;
|
||||
y = need(20, y);
|
||||
doc.rect(L, y, W, 14).fill(LIGHT_BG);
|
||||
doc.font("Helvetica-Bold").fontSize(7).fillColor(PRIMARY);
|
||||
doc.text(FASES[serv.fase] || `FASE ${serv.fase}`, L + 6, y + 3);
|
||||
y += 16;
|
||||
}
|
||||
|
||||
const servH = 13 + (serv.entregables ? Math.ceil(serv.entregables.length / 2) * 8 : 0) + 5;
|
||||
y = need(servH, y);
|
||||
|
||||
doc.font("Helvetica").fontSize(8).fillColor(DARK);
|
||||
doc.text(serv.nombre, colNombre + 6, y, { width: colTipo - colNombre - 16 });
|
||||
doc.font("Helvetica").fontSize(7).fillColor(MUTED);
|
||||
doc.text(tipoLabel, colTipo + 4, y);
|
||||
doc.text(serv.tiempoEntrega, colTiempo + 4, y, { width: 56 });
|
||||
doc.font("Helvetica-Bold").fontSize(8).fillColor(DARK);
|
||||
doc.text(fmt(serv.precio), colPrecio, y, { width: 60, align: "right" });
|
||||
y += 13;
|
||||
|
||||
if (serv.entregables && serv.entregables.length > 0) {
|
||||
const col1X = colNombre + 10;
|
||||
const col2X = colNombre + W * 0.48;
|
||||
const colW = W * 0.44;
|
||||
const half = Math.ceil(serv.entregables.length / 2);
|
||||
for (let i = 0; i < half; i++) {
|
||||
const e1 = serv.entregables[i];
|
||||
const e2 = i + half < serv.entregables.length ? serv.entregables[i + half] : null;
|
||||
doc.font("Helvetica").fontSize(6).fillColor(MUTED);
|
||||
doc.text(`\u2022 ${e1}`, col1X, y, { width: colW });
|
||||
if (e2) doc.text(`\u2022 ${e2}`, col2X, y, { width: colW });
|
||||
y += 8;
|
||||
}
|
||||
}
|
||||
|
||||
doc.save();
|
||||
doc.moveTo(colNombre + 6, y).lineTo(L + W, y).strokeColor("#e5e7eb").lineWidth(0.2).stroke();
|
||||
doc.restore();
|
||||
y += 4;
|
||||
}
|
||||
|
||||
y += 3;
|
||||
const total = servicios.reduce((s, x) => s + x.precio, 0);
|
||||
rowBg(y, 18, "#f8fafc");
|
||||
doc.font("Helvetica-Bold").fontSize(8.5).fillColor(PRIMARY);
|
||||
doc.text(tituloTotal, colNombre + 6, y + 4);
|
||||
doc.text(fmt(total), colPrecio, y + 4, { width: 60, align: "right" });
|
||||
y += 22;
|
||||
doc.font("Helvetica").fontSize(6).fillColor(MUTED);
|
||||
doc.text("(Precios en Moneda Nacional, no incluyen IVA)", colNombre + 6, y);
|
||||
y += 12;
|
||||
return y;
|
||||
}
|
||||
|
||||
if (unicos.length > 0) y = drawSection(unicos, "Unico", "Total Pago Unico");
|
||||
if (mensuales.length > 0) y = drawSection(mensuales, "Mensual", "Total Pago Mensual");
|
||||
|
||||
if (data.planBucefaloNivel) {
|
||||
y = need(20, y);
|
||||
const label = data.planBucefaloNivel.charAt(0).toUpperCase() + data.planBucefaloNivel.slice(1);
|
||||
rowBg(y, 14, LIGHT_BG);
|
||||
doc.font("Helvetica-Bold").fontSize(7.5).fillColor(DARK).text(`Plan Bucefalo CRM - ${label}`, colNombre + 6, y + 3);
|
||||
doc.font("Helvetica-Bold").text(fmt(data.planBucefaloPrecio), colPrecio, y + 3, { width: 60, align: "right" });
|
||||
y += 18;
|
||||
}
|
||||
|
||||
if (data.incluirBonos) {
|
||||
y = need(90, y);
|
||||
y += 4;
|
||||
doc.rect(L, y, 3, 10).fill(PRIMARY);
|
||||
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
|
||||
y += 16;
|
||||
const bonos = [
|
||||
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
|
||||
"Bono 2: Workshop Estrategico de Buyer Persona",
|
||||
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
|
||||
"Bono 4: 1 ano de Membresia Premium",
|
||||
"Bono 5: Un mes gratis de Bucefalo CRM",
|
||||
"Bono 6: Script de Ventas con mas de 100 complementos",
|
||||
];
|
||||
for (const b of bonos) {
|
||||
y = need(11, y);
|
||||
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2713 ${b}`, L + 8, y, { width: W - 16 });
|
||||
y += 11;
|
||||
}
|
||||
}
|
||||
|
||||
// ── T&C PAGE ──────────────────────────────────
|
||||
doc.addPage();
|
||||
y = m.top;
|
||||
|
||||
if (logoBuffer) {
|
||||
try { doc.image(logoBuffer, L + W - 70, m.top - 5, { height: 22 }); } catch {}
|
||||
}
|
||||
|
||||
doc.rect(L, y, 4, 16).fill(PRIMARY);
|
||||
doc.font("Helvetica-Bold").fontSize(15).fillColor(DARK).text("Terminos y Condiciones", L + 12, y + 1);
|
||||
y += 28;
|
||||
|
||||
function sectionTitle(title: string, yy: number): number {
|
||||
yy = need(20, yy);
|
||||
doc.rect(L, yy, 3, 9).fill(PRIMARY);
|
||||
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text(title, L + 10, yy);
|
||||
return yy + 15;
|
||||
}
|
||||
|
||||
function drawBullets(title: string, items: string[], yStart: number): number {
|
||||
let yy = sectionTitle(title, yStart);
|
||||
for (const t of items) {
|
||||
const h = txtHeight(`\u2022 ${t}`, W - 4, 7.5);
|
||||
yy = need(h + 4, yy);
|
||||
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(`\u2022 ${t}`, L + 2, yy, { width: W - 4 });
|
||||
yy += h + 4;
|
||||
}
|
||||
return yy + 6;
|
||||
}
|
||||
|
||||
y = drawBullets("Condiciones", [
|
||||
"Esta cotizacion tiene una vigencia de 15 dias habiles.",
|
||||
"Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.",
|
||||
"El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.",
|
||||
"Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.",
|
||||
"Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.",
|
||||
"Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.",
|
||||
], y);
|
||||
|
||||
y = drawBullets("Que no incluye el proyecto?", [
|
||||
"Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado.",
|
||||
"Redaccion de entradas de Blog.",
|
||||
"Integracion de Servicios de terceros ajenos a los cotizados.",
|
||||
"Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads.",
|
||||
"Creacion de Redes Sociales (En caso de requerir el servicio incluira un costo adicional).",
|
||||
], y);
|
||||
|
||||
y = drawBullets("Notas", [
|
||||
"El presente proyecto debera tener un responsable oficial.",
|
||||
"La Hora Centinela tiene un precio de $700.00 MXN.",
|
||||
"Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia. Si requiere los archivos editables, estos pueden ser adquiridos abonando una tarifa de liberacion (buy-out fee).",
|
||||
"Si el proyecto se pausa por razones ajenas a Uriel Jareth Consulting, esto generara costo extra del 15% al 30% para retomar el proyecto.",
|
||||
], y);
|
||||
|
||||
y += 6;
|
||||
y = need(110, y);
|
||||
doc.rect(L, y, 3, 9).fill(PRIMARY);
|
||||
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Datos Bancarios", L + 10, y);
|
||||
y += 16;
|
||||
|
||||
const cfg = data.configBancaria || {};
|
||||
const razonSocial = cfg.razon_social || "URIEL JARETH ALVARADO ORTIZ";
|
||||
const rfc = cfg.rfc || "AAOU970201SU7";
|
||||
const clabeNac = cfg.clabe_interbancaria || cfg.cuenta_nacional || "";
|
||||
const cuentaNac = cfg.cuenta_nacional || "";
|
||||
const bancoNac = cfg.cuenta_nacional ? "BBVA" : "";
|
||||
const cuentaInt = (cfg.cuenta_internacional_swift || cfg.cuenta_internacional || "").split("\n").filter(Boolean);
|
||||
const swiftMatch = cfg.cuenta_internacional_swift?.match(/SWIFT[^:]*:\s*(\S+)/i);
|
||||
const clabeIntMatch = cfg.cuenta_internacional_swift?.match(/CLABE[^:]*:\s*(\S+)/i);
|
||||
const swift = swiftMatch?.[1] || "BCMRMXMMPYM";
|
||||
const clabeInt = clabeIntMatch?.[1] || "";
|
||||
|
||||
const boxH = 48;
|
||||
doc.save();
|
||||
doc.rect(L, y, W, boxH).fill(LIGHT_BG).stroke(BORDER);
|
||||
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK).text("Transferencia Nacional", L + 8, y + 4);
|
||||
doc.font("Helvetica").fontSize(6.5).fillColor(DARK);
|
||||
if (cuentaNac) doc.text(`Cuenta: ${cuentaNac}`, L + 8, y + 15);
|
||||
if (clabeNac) doc.text(`CLABE: ${clabeNac}`, L + W * 0.45, y + 15);
|
||||
doc.text(`Razon Social: ${razonSocial}`, L + 8, y + 26);
|
||||
doc.text(`RFC: ${rfc}`, L + W * 0.45, y + 26);
|
||||
if (bancoNac) doc.text(`Banco: ${bancoNac}`, L + 8, y + 37);
|
||||
doc.restore();
|
||||
y += boxH + 6;
|
||||
|
||||
y = need(boxH + 6, y);
|
||||
doc.save();
|
||||
doc.rect(L, y, W, boxH).fill(LIGHT_BG).stroke(BORDER);
|
||||
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK).text("Transferencia Internacional", L + 8, y + 4);
|
||||
doc.font("Helvetica").fontSize(6.5).fillColor(DARK);
|
||||
doc.text(`Beneficiario: ${razonSocial}`, L + 8, y + 15);
|
||||
if (clabeInt) doc.text(`CLABE: ${clabeInt}`, L + W * 0.45, y + 15);
|
||||
doc.text(`Banco: BBVA Mexico`, L + 8, y + 26);
|
||||
doc.text(`SWIFT: ${swift}`, L + W * 0.45, y + 26);
|
||||
doc.restore();
|
||||
|
||||
// ── ADD FOOTERS TO ALL PAGES ──────────────────
|
||||
const savedBottom = doc.page.margins.bottom;
|
||||
const range = doc.bufferedPageRange();
|
||||
for (let i = range.start; i < range.start + range.count; i++) {
|
||||
doc.switchToPage(i);
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.save();
|
||||
doc.moveTo(L, ph - 42).lineTo(L + W, ph - 42).strokeColor(BORDER).lineWidth(0.5).stroke();
|
||||
doc.font("Helvetica").fontSize(6).fillColor(MUTED);
|
||||
doc.text(
|
||||
"Uriel Jareth Consulting",
|
||||
L, ph - 35, { width: W, align: "center", lineBreak: false }
|
||||
);
|
||||
doc.text(
|
||||
"urieljareth.com | [email protected] | (445) 182 9943",
|
||||
L, ph - 26, { width: W, align: "center", lineBreak: false }
|
||||
);
|
||||
doc.restore();
|
||||
doc.page.margins.bottom = savedBottom;
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const servicioSchema = z.object({
|
||||
catalogoId: z.string().min(1),
|
||||
nombre: z.string().min(1),
|
||||
fase: z.number().int().min(0).max(3),
|
||||
tipoPago: z.enum(["unico", "mensual"]),
|
||||
precio: z.number().min(0),
|
||||
tiempoEntrega: z.string().min(1),
|
||||
entregables: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const cotizacionPostSchema = z.object({
|
||||
numero: z.string().min(1),
|
||||
fecha: z.coerce.date(),
|
||||
vigencia: z.coerce.date(),
|
||||
moneda: z.enum(["MXN", "USD"]),
|
||||
tipoCambio: z.string(),
|
||||
proyecto: z.string(),
|
||||
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
|
||||
incluirBonos: z.boolean(),
|
||||
incluirFinanciamiento: z.boolean(),
|
||||
observaciones: z.string(),
|
||||
asesorId: z.string().min(1),
|
||||
cliente: z.object({
|
||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||
empresa: z.string(),
|
||||
email: z.string().email().or(z.literal("")),
|
||||
telefono: z.string(),
|
||||
}),
|
||||
servicios: z.array(servicioSchema),
|
||||
planBucefalo: z
|
||||
.object({
|
||||
nivel: z.string(),
|
||||
precio: z.number().min(0),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const cotizacionPutSchema = z.object({
|
||||
fecha: z.coerce.date(),
|
||||
vigencia: z.coerce.date(),
|
||||
moneda: z.enum(["MXN", "USD"]),
|
||||
tipoCambio: z.string(),
|
||||
proyecto: z.string(),
|
||||
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
|
||||
incluirBonos: z.boolean(),
|
||||
incluirFinanciamiento: z.boolean(),
|
||||
observaciones: z.string(),
|
||||
cliente: z.object({
|
||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||
empresa: z.string(),
|
||||
email: z.string().email().or(z.literal("")),
|
||||
telefono: z.string(),
|
||||
}),
|
||||
servicios: z.array(servicioSchema),
|
||||
planBucefalo: z
|
||||
.object({
|
||||
nivel: z.string(),
|
||||
precio: z.number().min(0),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email("Email invalido"),
|
||||
password: z.string().min(1, "Password es requerido"),
|
||||
});
|
||||
|
||||
export const servicioCatalogoSchema = z.object({
|
||||
nombre: z.string().min(1, "Nombre es requerido"),
|
||||
descripcion: z.string().nullable(),
|
||||
fase: z.number().int().min(0).max(3),
|
||||
tipoPago: z.enum(["unico", "mensual"]),
|
||||
precioBase: z.number().min(0, "Precio debe ser positivo"),
|
||||
tiempoEntrega: z.string().min(1),
|
||||
entregablesDefault: z.array(z.string()),
|
||||
categoriaId: z.string().min(1, "Categoria es requerida"),
|
||||
variante: z.string().nullable(),
|
||||
orden: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export function validateOrError<T>(
|
||||
schema: z.ZodType<T>,
|
||||
data: unknown
|
||||
): T | { error: string } {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) return result.data;
|
||||
return { error: result.error.issues.map((i) => i.message).join(", ") };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface ServicioSeleccionado {
|
||||
catalogoId: string;
|
||||
nombre: string;
|
||||
fase: number;
|
||||
tipoPago: string;
|
||||
precio: number;
|
||||
tiempoEntrega: string;
|
||||
entregables: string[];
|
||||
notas?: string;
|
||||
}
|
||||
|
||||
export interface CotizacionDraft {
|
||||
clienteNombre: string;
|
||||
clienteEmpresa: string;
|
||||
clienteEmail: string;
|
||||
clienteTelefono: string;
|
||||
asesorId: string;
|
||||
asesorNombre: string;
|
||||
fecha: Date;
|
||||
vigencia: Date | null;
|
||||
moneda: string;
|
||||
tipoCambio: string;
|
||||
proyecto: string;
|
||||
esquemaPago: string;
|
||||
incluirBonos: boolean;
|
||||
incluirFinanciamiento: boolean;
|
||||
planBucefaloNivel: string | null;
|
||||
servicios: ServicioSeleccionado[];
|
||||
observaciones: string;
|
||||
}
|
||||
|
||||
interface CotizacionStore {
|
||||
draft: CotizacionDraft;
|
||||
setField: <K extends keyof CotizacionDraft>(
|
||||
field: K,
|
||||
value: CotizacionDraft[K]
|
||||
) => void;
|
||||
toggleServicio: (servicio: ServicioSeleccionado) => void;
|
||||
updateServicio: (catalogoId: string, updates: Partial<ServicioSeleccionado>) => void;
|
||||
removeServicio: (catalogoId: string) => void;
|
||||
setServicios: (servicios: ServicioSeleccionado[]) => void;
|
||||
resetDraft: () => void;
|
||||
}
|
||||
|
||||
const initialDraft: CotizacionDraft = {
|
||||
clienteNombre: "",
|
||||
clienteEmpresa: "",
|
||||
clienteEmail: "",
|
||||
clienteTelefono: "",
|
||||
asesorId: "",
|
||||
asesorNombre: "",
|
||||
fecha: new Date(),
|
||||
vigencia: null,
|
||||
moneda: "MXN",
|
||||
tipoCambio: "NA",
|
||||
proyecto: "MKT Digital",
|
||||
esquemaPago: "Pago Unico/Mensual",
|
||||
incluirBonos: false,
|
||||
incluirFinanciamiento: false,
|
||||
planBucefaloNivel: null,
|
||||
servicios: [],
|
||||
observaciones: "",
|
||||
};
|
||||
|
||||
export const useCotizacionStore = create<CotizacionStore>((set) => ({
|
||||
draft: { ...initialDraft },
|
||||
|
||||
setField: (field, value) =>
|
||||
set((state) => ({ draft: { ...state.draft, [field]: value } })),
|
||||
|
||||
toggleServicio: (servicio) =>
|
||||
set((state) => {
|
||||
const exists = state.draft.servicios.find(
|
||||
(s) => s.catalogoId === servicio.catalogoId
|
||||
);
|
||||
if (exists) {
|
||||
return {
|
||||
draft: {
|
||||
...state.draft,
|
||||
servicios: state.draft.servicios.filter(
|
||||
(s) => s.catalogoId !== servicio.catalogoId
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
draft: {
|
||||
...state.draft,
|
||||
servicios: [...state.draft.servicios, servicio],
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
updateServicio: (catalogoId, updates) =>
|
||||
set((state) => ({
|
||||
draft: {
|
||||
...state.draft,
|
||||
servicios: state.draft.servicios.map((s) =>
|
||||
s.catalogoId === catalogoId ? { ...s, ...updates } : s
|
||||
),
|
||||
},
|
||||
})),
|
||||
|
||||
removeServicio: (catalogoId) =>
|
||||
set((state) => ({
|
||||
draft: {
|
||||
...state.draft,
|
||||
servicios: state.draft.servicios.filter(
|
||||
(s) => s.catalogoId !== catalogoId
|
||||
),
|
||||
},
|
||||
})),
|
||||
|
||||
setServicios: (servicios) =>
|
||||
set((state) => ({ draft: { ...state.draft, servicios } })),
|
||||
|
||||
resetDraft: () => set({ draft: { ...initialDraft } }),
|
||||
}));
|
||||
Reference in New Issue
Block a user