This commit is contained in:
2026-06-10 02:04:37 -06:00
parent 1ba3d8dea4
commit 31cdce03a0
131 changed files with 15922 additions and 106 deletions
+66
View File
@@ -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,
};
}
+85
View File
@@ -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);
}
+48
View File
@@ -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 {};
}
}
+21
View File
@@ -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;
+406
View File
@@ -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();
});
}
+90
View File
@@ -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(", ") };
}
+120
View File
@@ -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 } }),
}));