From 4f31d1c1716d9d4501e66e61b1b9be802ac4ad09 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 28 Aug 2026 06:58:10 +0000 Subject: [PATCH] feat(cotizador): historial, compartir WhatsApp, folio+QR, comparador de unidades, alertas de inventario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mis cotizaciones (guardar con cliente, retomar, nueva) asociadas al vendedor del token - Compartir: resumen copiable + deep link wa.me; PDF con folio, vigencia y QR - GET /api/cotizaciones/:id/publico (público, datos mínimos sin PII del cliente) - Comparador lado a lado de 2 unidades (mismo o distinto desarrollo) - POST /api/cotizaciones/alertas-inventario: avisa al vendedor si una unidad cotizada cambia de estatus (dedup; wire con syncs documentado) - Fix: labels de propuesta mapeados a los reales del board Oportunidades - Tests: api 201, cotizador 82 --- api-gateway/README.md | 20 +- api-gateway/prisma/schema.prisma | 6 + .../controllers/cotizaciones.controller.ts | 123 +++++- .../src/middleware/auth.middleware.test.ts | 24 ++ api-gateway/src/middleware/auth.middleware.ts | 11 +- api-gateway/src/routes/index.ts | 7 + .../alertas-inventario.service.test.ts | 147 ++++++++ .../services/alertas-inventario.service.ts | 104 ++++++ api-gateway/src/utils/folio.test.ts | 25 ++ api-gateway/src/utils/folio.ts | 18 + apps/cotizador/package-lock.json | 351 +++++++++++++++++- apps/cotizador/package.json | 2 + .../src/components/ComparadorUnidades.tsx | 184 +++++++++ .../src/components/MisCotizaciones.tsx | 120 ++++++ .../cotizador/src/components/ModalCliente.tsx | 131 +++++++ apps/cotizador/src/hooks/useCotizacion.ts | 47 +++ apps/cotizador/src/lib/comparadorUnidades.ts | 63 ++++ apps/cotizador/src/lib/compartir.ts | 62 ++++ apps/cotizador/src/lib/folio.ts | 8 + apps/cotizador/src/lib/herramientas.test.ts | 115 ++++++ apps/cotizador/src/lib/pdf.test.ts | 26 ++ apps/cotizador/src/lib/pdf.ts | 29 +- apps/cotizador/src/pages/CotizadorPage.tsx | 255 ++++++++++++- apps/cotizador/src/services/api.ts | 80 +++- 24 files changed, 1938 insertions(+), 20 deletions(-) create mode 100644 api-gateway/src/services/alertas-inventario.service.test.ts create mode 100644 api-gateway/src/services/alertas-inventario.service.ts create mode 100644 api-gateway/src/utils/folio.test.ts create mode 100644 api-gateway/src/utils/folio.ts create mode 100644 apps/cotizador/src/components/ComparadorUnidades.tsx create mode 100644 apps/cotizador/src/components/MisCotizaciones.tsx create mode 100644 apps/cotizador/src/components/ModalCliente.tsx create mode 100644 apps/cotizador/src/lib/comparadorUnidades.ts create mode 100644 apps/cotizador/src/lib/compartir.ts create mode 100644 apps/cotizador/src/lib/folio.ts create mode 100644 apps/cotizador/src/lib/herramientas.test.ts diff --git a/api-gateway/README.md b/api-gateway/README.md index 3575260..eb851b0 100644 --- a/api-gateway/README.md +++ b/api-gateway/README.md @@ -29,8 +29,11 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s | GET | `/api/health` | `{status, monday: 'up'|'down'}` | | GET | `/api/unidades` | Inventario desde Monday (`?disponibles=true` filtra; `?desarrollo=` filtra por DIAGONAL/ATELIER/ESTANZA/SENDA/SOLANA) | | GET | `/api/unidades/:codigo` | Una unidad (404 si no existe) | -| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday | +| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday. Asocia `vendedorId` = `sub` del JWT | | GET | `/api/cotizaciones` · `/api/cotizaciones/:id` | Consulta | +| GET | `/api/cotizaciones/mias` | Últimas 50 del vendedor autenticado (más recientes primero) | +| GET | `/api/cotizaciones/:id/publico` | **Público (sin auth)**, destino del QR del PDF: folio, desarrollo, unidad, propuesta, montos, vigencia y nombre del vendedor. NUNCA datos de contacto del cliente | +| POST | `/api/cotizaciones/alertas-inventario` | Alerta de cambio de estatus (ver abajo) | | POST | `/api/clientes` | Alta de cliente: persiste payload completo, crea Contacto y Apartado en Monday | | GET | `/api/clientes/:id` | Cliente con documentos | | POST | `/api/clientes/:id/documentos` | Multipart (campo `archivos`, máx 10, pdf/jpg/png, 10 MB) | @@ -57,6 +60,21 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s - **Sync de etapas** (`npm run sync:etapas -- [--archivo ] [--dry-run] [--semilla]`): sincroniza la columna status `Etapa` del tablero Unidades (pipeline de 10 etapas: Apartado → Reserva → Vendido → Integración → En Análisis → Autorizado → Notaría → Escriturado → Entrega → PostVenta) desde un JSON `[{codigo, etapa}]` (default: el maestro `reference/maestro/maestro-unidades.json`). Idempotente; al llegar a **Vendido**/**Escriturado** crea la actividad de traspaso de departamento (a Escrituración / a SAC) con dedup `traspaso::` en `AutomatizacionState`. En la PRIMERA corrida (carga histórica) usar `--semilla`: registra el dedup sin crear actividades retroactivas. - **Disparadores**: webhook `POST /api/webhooks/monday` (evento `create_item` en el board Leads) y, como respaldo mientras no haya URL pública, crons con node-cron: `POLL_CRON` (default `*/5 * * * *`) para round-robin y `0 9 * * *` para inactividad. Los crons se apagan con `MONDAY_AUTOMATIONS_ENABLED=false`. +## Alerta de inventario (cambio de estatus de unidad) + +`POST /api/cotizaciones/alertas-inventario` con body `{"cambios": [{"unidadCodigo": "U-101", "statusNuevo": "APARTADO"}]}` (máx 100). Por cada unidad busca cotizaciones de los últimos 30 días y crea una Actividad en Monday al vendedor de cada una ("La unidad X que cotizaste pasó a APARTADO", tipo Tarea, Abierto), solo si el vendedor tiene `mondayUserId` mapeado. Dedup persistente por unidad+cotización+estatus (clave `alerta-inv:::` en `AutomatizacionState`); si Monday falla, esa alerta NO queda deduplicada y el siguiente sync la reintenta. Responde `{cambiosProcesados, alertasCreadas, omitidas, fallidas}`. + +**Cómo invocarlo desde el sync**: al final de `sync-etapas.ts` / `sync-estatus-maestro.py`, recolectar los cambios de estatus aplicados y hacer un POST autenticado (Bearer JWT): + +```bash +curl -X POST "$API_URL/api/cotizaciones/alertas-inventario" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"cambios":[{"unidadCodigo":"U-101","statusNuevo":"APARTADO"}]}' +``` + +(Wire directo pendiente: los syncs actuales no reportan su lista de cambios; por eso queda como endpoint invocable.) + + ## Notas de arquitectura - **Monday service** (`src/services/monday.service.ts`): resuelve column IDs por TÍTULO en runtime (los slugs autogenerados nunca se hardcodean), paginación por cursor, retry con backoff ante 429/complejidad, `column_values` siempre via `JSON.stringify` en variables, cache de unidades 5 min con invalidación tras escrituras. diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index d8f2237..87bc0f2 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -13,6 +13,11 @@ datasource db { model Cotizacion { id String @id @default(uuid()) unidadCodigo String + // Desarrollo de la unidad al momento de cotizar (DIAGONAL | ATELIER | ...) + desarrollo String @default("DIAGONAL") + // Usuario del ecosistema que generó la cotización (sub del JWT); null en + // cotizaciones históricas o creadas sin auth (AUTH_ENABLED=false) + vendedorId String? clienteNombre String clienteEmail String? clienteTelefono String? @@ -30,6 +35,7 @@ model Cotizacion { createdAt DateTime @default(now()) @@index([unidadCodigo]) + @@index([vendedorId]) } model Cliente { diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index 5f41240..b2cb7ca 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -2,8 +2,13 @@ import type { Request, Response } from 'express' import { z } from 'zod' import { mondayService } from '../services/monday.service.js' import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js' +import { + procesarAlertasInventario, + type ResultadoAlertas, +} from '../services/alertas-inventario.service.js' import { prisma } from '../services/prisma.js' import { NotFoundError } from '../utils/errors.js' +import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js' const crearCotizacionSchema = z.object({ unidadCodigo: z.string().min(1), @@ -23,6 +28,15 @@ const hoyISO = (): string => { return `${d.getFullYear()}-${m}-${dia}` } +// Labels reales de la columna status "Tipo de Propuesta" del tablero Oportunidades +const LABEL_PROPUESTA_MONDAY: Record = { + BANCARIO: 'CREDITO BANCARIO', + INFONAVIT: 'INFONAVIT-COFINAVIT', + COFINAVIT: 'INFONAVIT-COFINAVIT', + CONTADO: 'CONTADO', + SEMI_CONTADO: 'SEMI CONTADO', +} + /** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */ export async function crearCotizacion(req: Request, res: Response): Promise { const body = crearCotizacionSchema.parse(req.body) @@ -37,6 +51,8 @@ export async function crearCotizacion(req: Request, res: Response): Promise { + const vendedorId = req.usuarioToken?.sub + if (!vendedorId) { + res.json([]) + return + } + const cotizaciones = await prisma.cotizacion.findMany({ + where: { vendedorId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }) + res.json(cotizaciones.map((c) => ({ ...c, folio: folioCotizacion(c.id) }))) } /** GET /api/cotizaciones */ @@ -89,3 +120,91 @@ export async function obtenerCotizacion(req: Request, res: Response): Promise { + const cotizacion = await prisma.cotizacion.findUnique({ + where: { id: String(req.params['id']) }, + }) + if (!cotizacion) throw new NotFoundError('Cotización no encontrada') + + let vendedorNombre: string | null = null + if (cotizacion.vendedorId) { + const vendedor = await prisma.usuario.findUnique({ + where: { id: cotizacion.vendedorId }, + select: { nombre: true }, + }) + vendedorNombre = vendedor?.nombre ?? null + } + + res.json({ + folio: folioCotizacion(cotizacion.id), + desarrollo: cotizacion.desarrollo, + unidadCodigo: cotizacion.unidadCodigo, + tipoPropuesta: cotizacion.tipoPropuesta, + precioFinal: cotizacion.precioFinal, + enganche: cotizacion.enganche, + mensualidad: cotizacion.mensualidad, + pagoFinal: cotizacion.pagoFinal, + vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(), + vendedorNombre, + }) +} + +const alertasInventarioSchema = z.object({ + cambios: z + .array( + z.object({ + unidadCodigo: z.string().min(1), + statusNuevo: z.string().min(1), + }), + ) + .min(1) + .max(100), +}) + +/** + * POST /api/cotizaciones/alertas-inventario — lo invoca el sync de estatus al + * detectar cambios (ver README). Crea una Actividad en Monday al vendedor de + * cada cotización reciente (≤30 días) de la unidad afectada, con dedup + * persistente por unidad+cotización+estatus. + */ +export async function alertasInventarioEndpoint(req: Request, res: Response): Promise { + const { cambios } = alertasInventarioSchema.parse(req.body) + + const resultado: ResultadoAlertas = await procesarAlertasInventario( + { + buscarCotizacionesRecientes: (unidadCodigo, desde) => + prisma.cotizacion.findMany({ + where: { unidadCodigo, createdAt: { gte: desde } }, + select: { id: true, vendedorId: true }, + }), + mondayUserIdDeVendedor: async (vendedorId) => { + const usuario = await prisma.usuario.findUnique({ + where: { id: vendedorId }, + select: { mondayUserId: true }, + }) + return usuario?.mondayUserId ?? null + }, + crearActividad: (datos) => mondayService.crearActividad(datos), + yaAlertada: async (clave) => { + const estado = await prisma.automatizacionState.findUnique({ where: { clave } }) + return estado !== null + }, + marcarAlertada: async (clave) => { + await prisma.automatizacionState.upsert({ + where: { clave }, + create: { clave, valor: new Date().toISOString() }, + update: { valor: new Date().toISOString() }, + }) + }, + }, + cambios, + ) + + res.json(resultado) +} diff --git a/api-gateway/src/middleware/auth.middleware.test.ts b/api-gateway/src/middleware/auth.middleware.test.ts index 1176d99..24fa02c 100644 --- a/api-gateway/src/middleware/auth.middleware.test.ts +++ b/api-gateway/src/middleware/auth.middleware.test.ts @@ -70,6 +70,30 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => { } }) + it('deja pasar GET /cotizaciones/:id/publico sin token (destino del QR)', () => { + vi.clearAllMocks() + authMiddleware( + reqFake({ method: 'GET', path: '/cotizaciones/abc-123/publico' }), + resFake().res, + next, + ) + expect(next).toHaveBeenCalledOnce() + }) + + it('el patrón público no abre otras rutas de cotizaciones', () => { + const casos: Partial[] = [ + { method: 'GET', path: '/cotizaciones/abc-123' }, + { method: 'GET', path: '/cotizaciones/mias' }, + { method: 'POST', path: '/cotizaciones/abc-123/publico' }, + { method: 'GET', path: '/cotizaciones/abc-123/publico/extra' }, + ] + for (const c of casos) { + const { res, status } = resFake() + authMiddleware(reqFake(c), res, next) + expect(status).toHaveBeenCalledWith(401) + } + }) + it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => { const { res, status } = resFake() authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next) diff --git a/api-gateway/src/middleware/auth.middleware.ts b/api-gateway/src/middleware/auth.middleware.ts index 91c9770..34a0ec3 100644 --- a/api-gateway/src/middleware/auth.middleware.ts +++ b/api-gateway/src/middleware/auth.middleware.ts @@ -12,6 +12,13 @@ const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [ { metodo: 'POST', ruta: '/webhooks/monday' }, ] +// Rutas públicas con parámetros (se comparan contra req.path relativo a /api). +// El endpoint público de cotización es el destino del QR del PDF: el cliente +// final lo abre sin sesión; devuelve solo datos comerciales, nunca del cliente. +const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [ + { metodo: 'GET', patron: /^\/cotizaciones\/[^/]+\/publico$/ }, +] + declare module 'express-serve-static-core' { interface Request { usuarioToken?: TokenPayload @@ -28,7 +35,9 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): return } - const publica = RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta) + const publica = + RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta) || + PATRONES_PUBLICOS.some((r) => r.metodo === req.method && r.patron.test(req.path)) if (publica) { next() return diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index d7ef9be..eb3dca2 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -2,9 +2,12 @@ import { Router } from 'express' import { health } from '../controllers/health.controller.js' import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js' import { + alertasInventarioEndpoint, crearCotizacion, listarCotizaciones, + listarMisCotizaciones, obtenerCotizacion, + obtenerCotizacionPublica, } from '../controllers/cotizaciones.controller.js' import { crearCliente, @@ -59,6 +62,10 @@ apiRouter.get('/unidades/:codigo', obtenerUnidad) apiRouter.post('/cotizaciones', crearCotizacion) apiRouter.get('/cotizaciones', listarCotizaciones) +apiRouter.get('/cotizaciones/mias', listarMisCotizaciones) +apiRouter.post('/cotizaciones/alertas-inventario', alertasInventarioEndpoint) +// Público (sin auth): destino del QR del PDF; ver RUTAS_PUBLICAS/PATRONES_PUBLICOS +apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica) apiRouter.get('/cotizaciones/:id', obtenerCotizacion) apiRouter.post('/clientes', crearCliente) diff --git a/api-gateway/src/services/alertas-inventario.service.test.ts b/api-gateway/src/services/alertas-inventario.service.test.ts new file mode 100644 index 0000000..f4b3449 --- /dev/null +++ b/api-gateway/src/services/alertas-inventario.service.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest' +import { + procesarAlertasInventario, + VENTANA_ALERTA_DIAS, + type AlertasInventarioDeps, + type CotizacionParaAlerta, +} from './alertas-inventario.service.js' + +interface Opciones { + cotizaciones?: CotizacionParaAlerta[] + mondayUserId?: number | null + yaAlertada?: boolean + fallaMonday?: boolean +} + +/** Dependencias mockeadas: registra actividades y claves dedup marcadas */ +function depsMock(opts: Opciones = {}) { + const dedup = new Set() + if (opts.yaAlertada) dedup.add('*') // comodín: todo ya alertado + const deps: AlertasInventarioDeps & { + actividades: { nombre: string; responsableId: number }[] + consultas: { unidadCodigo: string; desde: Date }[] + } = { + actividades: [], + consultas: [], + async buscarCotizacionesRecientes(unidadCodigo, desde) { + this.consultas.push({ unidadCodigo, desde }) + return opts.cotizaciones ?? [] + }, + async mondayUserIdDeVendedor() { + return opts.mondayUserId === undefined ? 12345 : opts.mondayUserId + }, + async crearActividad(datos) { + if (opts.fallaMonday) throw new Error('Monday caído') + this.actividades.push({ nombre: datos.nombre, responsableId: datos.responsableId }) + return 'act-1' + }, + async yaAlertada(clave) { + return dedup.has('*') || dedup.has(clave) + }, + async marcarAlertada(clave) { + dedup.add(clave) + }, + } + return deps +} + +const AHORA = new Date('2026-08-28T12:00:00') + +describe('procesarAlertasInventario', () => { + it('crea la actividad al vendedor con mondayUserId y texto de estatus', async () => { + const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] }) + const r = await procesarAlertasInventario( + deps, + [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }], + AHORA, + ) + expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 }) + expect(deps.actividades).toEqual([ + { nombre: 'La unidad U-101 que cotizaste pasó a APARTADO', responsableId: 12345 }, + ]) + }) + + it('busca cotizaciones en ventana de 30 días hacia atrás', async () => { + const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] }) + await procesarAlertasInventario(deps, [{ unidadCodigo: 'U-101', statusNuevo: 'VENDIDA' }], AHORA) + const esperado = new Date(AHORA.getTime() - VENTANA_ALERTA_DIAS * 86400000) + expect(deps.consultas[0]?.desde.getTime()).toBe(esperado.getTime()) + }) + + it('dedup por unidad+cotización+estatus: no repite la alerta', async () => { + const deps = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }] }) + const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' } + await procesarAlertasInventario(deps, [cambio], AHORA) + const r = await procesarAlertasInventario(deps, [cambio], AHORA) + expect(r.alertasCreadas).toBe(0) + expect(r.omitidas).toBe(1) + expect(deps.actividades).toHaveLength(1) + }) + + it('omite cotizaciones sin vendedor o sin mapeo Monday', async () => { + const sinVendedor = depsMock({ cotizaciones: [{ id: 'cot-1', vendedorId: null }] }) + const r1 = await procesarAlertasInventario( + sinVendedor, + [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }], + AHORA, + ) + expect(r1.omitidas).toBe(1) + expect(sinVendedor.actividades).toHaveLength(0) + + const sinMapeo = depsMock({ + cotizaciones: [{ id: 'cot-2', vendedorId: 'ven-2' }], + mondayUserId: null, + }) + const r2 = await procesarAlertasInventario( + sinMapeo, + [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }], + AHORA, + ) + expect(r2.omitidas).toBe(1) + expect(sinMapeo.actividades).toHaveLength(0) + }) + + it('si Monday falla no marca dedup (reintentable) y cuenta fallidas', async () => { + const deps = depsMock({ + cotizaciones: [{ id: 'cot-1', vendedorId: 'ven-1' }], + fallaMonday: true, + }) + const cambio = { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' } + const r1 = await procesarAlertasInventario(deps, [cambio], AHORA) + expect(r1.fallidas).toBe(1) + // Segundo intento vuelve a intentarlo (no quedó deduplicada) + const r2 = await procesarAlertasInventario(deps, [cambio], AHORA) + expect(r2.fallidas).toBe(1) + }) + + it('procesa varias cotizaciones y varios cambios', async () => { + const deps = depsMock({ + cotizaciones: [ + { id: 'cot-1', vendedorId: 'ven-1' }, + { id: 'cot-2', vendedorId: 'ven-2' }, + ], + }) + const r = await procesarAlertasInventario( + deps, + [ + { unidadCodigo: 'U-101', statusNuevo: 'APARTADO' }, + { unidadCodigo: 'U-102', statusNuevo: 'VENDIDA' }, + ], + AHORA, + ) + expect(r.cambiosProcesados).toBe(2) + expect(r.alertasCreadas).toBe(4) // 2 cambios × 2 cotizaciones + }) + + it('sin cotizaciones recientes no hace nada', async () => { + const deps = depsMock({ cotizaciones: [] }) + const r = await procesarAlertasInventario( + deps, + [{ unidadCodigo: 'U-999', statusNuevo: 'APARTADO' }], + AHORA, + ) + expect(r).toEqual({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 0, fallidas: 0 }) + expect(deps.actividades).toHaveLength(0) + expect(vi.isMockFunction(deps.crearActividad)).toBe(false) // función real del mock, sin llamadas + }) +}) diff --git a/api-gateway/src/services/alertas-inventario.service.ts b/api-gateway/src/services/alertas-inventario.service.ts new file mode 100644 index 0000000..c7f05bb --- /dev/null +++ b/api-gateway/src/services/alertas-inventario.service.ts @@ -0,0 +1,104 @@ +/** + * Alerta de inventario: cuando una unidad cambia de estatus, se busca si hay + * cotizaciones recientes (≤30 días) de esa unidad y se crea una Actividad en + * Monday al vendedor de cada cotización (solo si tiene mondayUserId mapeado). + * + * Lógica pura con dependencias inyectadas para poder probarla con mocks; el + * controller la cablea con Prisma y MondayService. + */ + +export interface CambioEstatusUnidad { + unidadCodigo: string + statusNuevo: string // p. ej. APARTADO | VENDIDA | BLOQUEADO +} + +export interface CotizacionParaAlerta { + id: string + vendedorId: string | null +} + +export interface AlertasInventarioDeps { + /** Cotizaciones de la unidad creadas desde `desde` (ventana de 30 días) */ + buscarCotizacionesRecientes(unidadCodigo: string, desde: Date): Promise + /** mondayUserId del vendedor; null si no existe o no está mapeado */ + mondayUserIdDeVendedor(vendedorId: string): Promise + /** Crea la Actividad en Monday y devuelve su item id */ + crearActividad(datos: { + nombre: string + tipoLabel: string + responsableId: number + fecha: string // 'YYYY-MM-DD' + estadoLabel: string + }): Promise + /** Dedup persistente por clave (unidad+cotización+estatus) */ + yaAlertada(clave: string): Promise + marcarAlertada(clave: string): Promise +} + +export interface ResultadoAlertas { + cambiosProcesados: number + alertasCreadas: number + omitidas: number // sin vendedor, sin mapeo Monday o ya alertadas + fallidas: number // Monday falló; NO quedan deduplicadas (reintentables) +} + +export const VENTANA_ALERTA_DIAS = 30 + +const claveDedup = (cambio: CambioEstatusUnidad, cotizacionId: string): string => + `alerta-inv:${cambio.unidadCodigo}:${cotizacionId}:${cambio.statusNuevo}` + +const hoyISO = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + +/** Procesa los cambios de estatus y crea las actividades correspondientes */ +export async function procesarAlertasInventario( + deps: AlertasInventarioDeps, + cambios: CambioEstatusUnidad[], + ahora: Date = new Date(), +): Promise { + const desde = new Date(ahora.getTime() - VENTANA_ALERTA_DIAS * 86400000) + const resultado: ResultadoAlertas = { + cambiosProcesados: 0, + alertasCreadas: 0, + omitidas: 0, + fallidas: 0, + } + + for (const cambio of cambios) { + resultado.cambiosProcesados += 1 + const cotizaciones = await deps.buscarCotizacionesRecientes(cambio.unidadCodigo, desde) + + for (const cotizacion of cotizaciones) { + const clave = claveDedup(cambio, cotizacion.id) + if (await deps.yaAlertada(clave)) { + resultado.omitidas += 1 + continue + } + if (!cotizacion.vendedorId) { + resultado.omitidas += 1 + continue + } + const mondayUserId = await deps.mondayUserIdDeVendedor(cotizacion.vendedorId) + if (mondayUserId === null) { + resultado.omitidas += 1 + continue + } + try { + await deps.crearActividad({ + nombre: `La unidad ${cambio.unidadCodigo} que cotizaste pasó a ${cambio.statusNuevo}`, + tipoLabel: 'Tarea', + responsableId: mondayUserId, + fecha: hoyISO(ahora), + estadoLabel: 'Abierto', + }) + await deps.marcarAlertada(clave) + resultado.alertasCreadas += 1 + } catch { + // Monday falló: no se marca dedup para que el siguiente sync reintente + resultado.fallidas += 1 + } + } + } + + return resultado +} diff --git a/api-gateway/src/utils/folio.test.ts b/api-gateway/src/utils/folio.test.ts new file mode 100644 index 0000000..919ce2f --- /dev/null +++ b/api-gateway/src/utils/folio.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { folioCotizacion, vigenciaCotizacion, VIGENCIA_DIAS } from './folio.js' + +describe('folioCotizacion', () => { + it('deriva COT-XXXXXX (6 hex mayúsculas) del uuid', () => { + expect(folioCotizacion('3f8a1b2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3')).toBe('COT-3F8A1B') + }) + + it('es determinista: mismo id, mismo folio', () => { + const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + expect(folioCotizacion(id)).toBe(folioCotizacion(id)) + }) + + it('tolera ids sin hex suficiente usando el id en mayúsculas', () => { + expect(folioCotizacion('xyz')).toBe('COT-XYZ') + }) +}) + +describe('vigenciaCotizacion', () => { + it('suma VIGENCIA_DIAS días naturales a la emisión', () => { + const creada = new Date('2026-08-28T12:00:00') + const limite = vigenciaCotizacion(creada) + expect(limite.getDate() - creada.getDate()).toBe(VIGENCIA_DIAS) + }) +}) diff --git a/api-gateway/src/utils/folio.ts b/api-gateway/src/utils/folio.ts new file mode 100644 index 0000000..893392e --- /dev/null +++ b/api-gateway/src/utils/folio.ts @@ -0,0 +1,18 @@ +/** + * Folio corto de cotización, derivado de forma determinista del id (uuid). + * Espejo de apps/cotizador/src/lib/folio.ts (mantener sincronizados). + */ +export function folioCotizacion(id: string): string { + const hex = id.replace(/[^0-9a-fA-F]/g, '').toUpperCase() + return `COT-${(hex || id.toUpperCase()).slice(0, 6)}` +} + +/** Días naturales de vigencia de la cotización (igual que en el PDF del cotizador) */ +export const VIGENCIA_DIAS = 3 + +/** Fecha límite de vigencia: emisión + VIGENCIA_DIAS días naturales */ +export function vigenciaCotizacion(creada: Date): Date { + const limite = new Date(creada) + limite.setDate(limite.getDate() + VIGENCIA_DIAS) + return limite +} diff --git a/apps/cotizador/package-lock.json b/apps/cotizador/package-lock.json index 9d4cce6..b76cf99 100644 --- a/apps/cotizador/package-lock.json +++ b/apps/cotizador/package-lock.json @@ -12,6 +12,7 @@ "@hookform/resolvers": "^3.9.1", "jspdf": "^2.5.2", "jspdf-autotable": "^3.8.4", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", @@ -20,6 +21,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", @@ -1589,6 +1591,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -1906,6 +1928,15 @@ "node": ">=8" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001809", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", @@ -1974,11 +2005,91 @@ "node": ">= 16" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1991,7 +2102,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -2079,6 +2189,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2099,6 +2218,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dompurify": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", @@ -2244,6 +2369,19 @@ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2286,6 +2424,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2383,7 +2530,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2797,6 +2943,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2927,6 +3085,42 @@ "node": ">=18" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2934,6 +3128,15 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3012,6 +3215,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -3041,6 +3253,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -3143,6 +3372,21 @@ "license": "MIT", "optional": true }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rgbcolor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", @@ -3215,6 +3459,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -3546,6 +3796,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", @@ -4774,6 +5031,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4889,6 +5152,12 @@ "node": ">=8" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4896,6 +5165,82 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/apps/cotizador/package.json b/apps/cotizador/package.json index ac571cf..c1d3855 100644 --- a/apps/cotizador/package.json +++ b/apps/cotizador/package.json @@ -15,6 +15,7 @@ "@hookform/resolvers": "^3.9.1", "jspdf": "^2.5.2", "jspdf-autotable": "^3.8.4", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", @@ -23,6 +24,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", diff --git a/apps/cotizador/src/components/ComparadorUnidades.tsx b/apps/cotizador/src/components/ComparadorUnidades.tsx new file mode 100644 index 0000000..042275e --- /dev/null +++ b/apps/cotizador/src/components/ComparadorUnidades.tsx @@ -0,0 +1,184 @@ +import { useMemo, useState } from 'react' +import type { Unidad, TipoPropuesta } from '../types' +import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades' +import { formatoMoneda } from '../lib/formatters' +import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos' + +interface Props { + unidades: Unidad[] + /** Tipo de propuesta activa en el cotizador (se aplica a ambas unidades) */ + tipo: TipoPropuesta + apartado: number + acabados: boolean +} + +const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`) + +interface Fila { + etiqueta: string + obtener: (c: ColumnaComparador) => string + destacada?: boolean +} + +/** + * Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado, + * con la propuesta activa calculada por el motor con los parámetros de cada + * desarrollo. + */ +export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) { + const [abierto, setAbierto] = useState(false) + const [codigoA, setCodigoA] = useState('') + const [codigoB, setCodigoB] = useState('') + + const porCodigo = useMemo(() => { + const m = new Map() + for (const u of unidades) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u) + return m + }, [unidades]) + + const unidadA = porCodigo.get(codigoA) ?? null + const unidadB = porCodigo.get(codigoB) ?? null + + const comparacion = useMemo( + () => (unidadA && unidadB ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados) : null), + [unidadA, unidadB, tipo, apartado, acabados], + ) + + const filas: Fila[] = [ + { etiqueta: 'Desarrollo', obtener: (c) => c.desarrolloNombre }, + { etiqueta: 'Modelo', obtener: (c) => c.unidad.modelo }, + { etiqueta: 'Estatus', obtener: (c) => c.unidad.status }, + { etiqueta: 'Sup. construcción', obtener: (c) => formatoM2(c.supConstruccion) }, + { etiqueta: 'Precio de lista', obtener: (c) => formatoMoneda(c.precioLista) }, + { etiqueta: 'Precio final c/desc.', obtener: (c) => formatoMoneda(c.propuesta.precioFinal) }, + { etiqueta: 'Enganche', obtener: (c) => formatoMoneda(c.propuesta.enganche) }, + { + etiqueta: 'Mensualidad', + obtener: (c) => + c.propuesta.notaMensualidades + ? `— (${c.propuesta.notaMensualidades})` + : c.propuesta.mensualidad === null + ? 'N/A' + : `${c.propuesta.numMensualidades} × ${formatoMoneda(c.propuesta.mensualidad)}`, + }, + { etiqueta: 'Pago final', obtener: (c) => formatoMoneda(c.propuesta.pagoFinal) }, + { + etiqueta: 'Total pagado', + obtener: (c) => (c.total === null ? '—' : formatoMoneda(c.total)), + destacada: true, + }, + ] + + const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => ( +
+ + +
+ ) + + return ( +
+
+

+ Comparar unidades +

+ +
+ + {abierto && ( +
+
+ {selector('comparador-a', codigoA, setCodigoA, 'Unidad A')} + {selector('comparador-b', codigoB, setCodigoB, 'Unidad B')} +
+ + {!comparacion ? ( +

+ Selecciona dos unidades para ver la comparación lado a lado. +

+ ) : ( +
+ + + + + {[comparacion.a, comparacion.b].map((c) => ( + + ))} + + + + {filas.map((fila) => ( + + + {[comparacion.a, comparacion.b].map((c) => ( + + ))} + + ))} + +
+ Concepto + + {c.unidad.codigo} +
+ {fila.etiqueta} + + {fila.obtener(c)} +
+

+ Cada unidad se calcula con los parámetros de su desarrollo y el mismo apartado. + El enganche ya incluye la deducción del apartado. +

+
+ )} +
+ )} +
+ ) +} diff --git a/apps/cotizador/src/components/MisCotizaciones.tsx b/apps/cotizador/src/components/MisCotizaciones.tsx new file mode 100644 index 0000000..c66fc1e --- /dev/null +++ b/apps/cotizador/src/components/MisCotizaciones.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' +import type { CotizacionGuardada } from '../services/api' +import { formatoMoneda } from '../lib/formatters' + +interface Props { + cotizaciones: CotizacionGuardada[] + cargando: boolean + error: string | null + /** Restaura la cotización en el cotizador (unidad + parámetros) */ + onRetomar: (c: CotizacionGuardada) => void +} + +const TITULO_PROPUESTA: Record = { + BANCARIO: 'Crédito Bancario', + INFONAVIT: 'Crédito Infonavit-Cofinavit', + COFINAVIT: 'Crédito Infonavit-Cofinavit', + CONTADO: 'Contado', + SEMI_CONTADO: 'Semi Contado', +} + +const formatoFechaHora = (iso: string): string => + new Date(iso).toLocaleString('es-MX', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + +/** Sección colapsable "Mis cotizaciones": historial del vendedor logueado */ +export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Props) { + const [abierto, setAbierto] = useState(false) + + return ( +
+
+

+ Mis cotizaciones +

+ +
+ + {abierto && ( +
+ {cargando &&

Cargando historial…

} + {error &&

{error}

} + {!cargando && !error && cotizaciones.length === 0 && ( +

+ Aún no tienes cotizaciones guardadas. Se guardan al generar el PDF. +

+ )} + {cotizaciones.length > 0 && ( +
+ + + + {['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Precio final', ''].map( + (h) => ( + + ), + )} + + + + {cotizaciones.map((c) => ( + + + + + + + + + + ))} + +
+ {h} +
+ {formatoFechaHora(c.createdAt)} + + {c.folio ?? '—'} + {c.clienteNombre} + {c.unidadCodigo} + {c.desarrollo} + + {TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta} + + {formatoMoneda(c.precioFinal)} + + +
+
+ )} +
+ )} +
+ ) +} diff --git a/apps/cotizador/src/components/ModalCliente.tsx b/apps/cotizador/src/components/ModalCliente.tsx new file mode 100644 index 0000000..ced99ef --- /dev/null +++ b/apps/cotizador/src/components/ModalCliente.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react' +import type { Cliente } from '../types' + +interface Props { + abierto: boolean + nombreInicial?: string + telefonoInicial?: string + emailInicial?: string + /** Aceptar con los datos capturados (nombre mínimo 3 caracteres) */ + onAceptar: (cliente: Cliente) => void + onCancelar: () => void +} + +const campoInput = + 'w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none' +const etiquetaCampo = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Modal sencillo para pedir los datos del cliente cuando se va a + * guardar/generar el PDF y aún no se capturaron en el panel. + */ +export function ModalCliente({ abierto, nombreInicial = '', telefonoInicial = '', emailInicial = '', onAceptar, onCancelar }: Props) { + const [nombre, setNombre] = useState(nombreInicial) + const [telefono, setTelefono] = useState(telefonoInicial) + const [email, setEmail] = useState(emailInicial) + const [error, setError] = useState(null) + + useEffect(() => { + if (abierto) { + setNombre(nombreInicial) + setTelefono(telefonoInicial) + setEmail(emailInicial) + setError(null) + } + }, [abierto, nombreInicial, telefonoInicial, emailInicial]) + + if (!abierto) return null + + const aceptar = () => { + if (nombre.trim().length < 3) { + setError('El nombre del cliente es requerido (mínimo 3 caracteres)') + return + } + onAceptar({ + nombre: nombre.trim(), + telefono: telefono.trim() || undefined, + email: email.trim() || undefined, + }) + } + + return ( +
+
+ +

+ Se necesitan para guardar la cotización y generar el PDF. +

+ +
+
+ + setNombre(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && aceptar()} + className={campoInput} + /> +
+
+ + setTelefono(e.target.value)} + className={campoInput} + /> +
+
+ + setEmail(e.target.value)} + className={campoInput} + /> +
+
+ + {error &&

{error}

} + +
+ + +
+
+
+ ) +} diff --git a/apps/cotizador/src/hooks/useCotizacion.ts b/apps/cotizador/src/hooks/useCotizacion.ts index c27b754..734b66b 100644 --- a/apps/cotizador/src/hooks/useCotizacion.ts +++ b/apps/cotizador/src/hooks/useCotizacion.ts @@ -28,9 +28,27 @@ interface UseCotizacionResult { errorApartado: string | null descuentos: DescuentosPropuestas setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void + /** Índice de la propuesta elegida (para guardar/compartir) */ + propuestaIndex: number + setPropuestaIndex: (i: number) => void precioLista: number numMeses: number | null propuestas: PropuestaResultado[] + /** Restaura el estado completo desde una cotización guardada (historial) */ + restaurar: (estado: EstadoRestaurar) => void + /** Reinicia la cotización (botón "Nueva") */ + nueva: () => void +} + +export interface EstadoRestaurar { + desarrollo: string + /** Unidad ya resuelta del inventario cargado */ + unidad: Unidad + acabados: boolean + apartado: number + /** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */ + descuento?: number + propuestaIndex?: number } /** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */ @@ -40,6 +58,7 @@ export function useCotizacion(): UseCotizacionResult { const [acabados, setAcabados] = useState(false) const [apartado, setApartado] = useState(APARTADO_DEFAULT) const [descuentos, setDescuentos] = useState(descuentosDefault(DESARROLLO_DEFAULT)) + const [propuestaIndex, setPropuestaIndex] = useState(0) const params = obtenerDesarrollo(desarrollo) @@ -48,12 +67,36 @@ export function useCotizacion(): UseCotizacionResult { setUnidad(null) setAcabados(false) setDescuentos(descuentosDefault(id)) + setPropuestaIndex(0) } const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => { setDescuentos((prev) => ({ ...prev, [clave]: valor })) } + const restaurar = (estado: EstadoRestaurar) => { + setDesarrolloState(estado.desarrollo) + setUnidad(estado.unidad) + setAcabados(estado.acabados) + setApartado(estado.apartado) + const base = descuentosDefault(estado.desarrollo) + if (estado.descuento !== undefined) { + const idx = estado.propuestaIndex ?? 0 + const clave = obtenerDesarrollo(estado.desarrollo).propuestas[idx]?.claveDescuento + if (clave) base[clave] = estado.descuento + } + setDescuentos(base) + setPropuestaIndex(estado.propuestaIndex ?? 0) + } + + const nueva = () => { + setUnidad(null) + setAcabados(false) + setApartado(APARTADO_DEFAULT) + setDescuentos(descuentosDefault(desarrollo)) + setPropuestaIndex(0) + } + const numMeses = useMemo( () => (unidad ? resolverNumMensualidades(unidad, params) : null), [unidad, params], @@ -87,8 +130,12 @@ export function useCotizacion(): UseCotizacionResult { errorApartado, descuentos, setDescuento, + propuestaIndex, + setPropuestaIndex, precioLista, numMeses, propuestas, + restaurar, + nueva, } } diff --git a/apps/cotizador/src/lib/comparadorUnidades.ts b/apps/cotizador/src/lib/comparadorUnidades.ts new file mode 100644 index 0000000..3e9ee6d --- /dev/null +++ b/apps/cotizador/src/lib/comparadorUnidades.ts @@ -0,0 +1,63 @@ +import type { Unidad, TipoPropuesta } from '../types' +import { + calcularPropuestas, + obtenerPrecioLista, + totalPagado, + type PropuestaResultado, +} from './calculations' +import { descuentosDefault, obtenerDesarrollo } from '../data/desarrollos' + +export interface ColumnaComparador { + unidad: Unidad + desarrolloId: string + desarrolloNombre: string + precioLista: number + supConstruccion: number | null + /** La propuesta del tipo activo, calculada con los defaults del desarrollo de la unidad */ + propuesta: PropuestaResultado + /** Total pagado (apartado + enganche + mensualidades + pago final); null si no se puede conocer */ + total: number | null +} + +export interface ComparacionUnidades { + a: ColumnaComparador + b: ColumnaComparador +} + +/** + * Comparador de 2 unidades (del mismo o distinto desarrollo): calcula para + * cada una la propuesta del tipo activo con los parámetros de SU desarrollo. + * El apartado y acabados se aplican igual a ambas (acabados se ignora en + * desarrollos de precio único, igual que en el cotizador). + */ +export function compararUnidades( + unidadA: Unidad, + unidadB: Unidad, + tipo: TipoPropuesta, + apartado: number, + acabados: boolean, +): ComparacionUnidades { + const columna = (unidad: Unidad): ColumnaComparador => { + const desarrolloId = unidad.desarrollo ?? 'DIAGONAL' + const params = obtenerDesarrollo(desarrolloId) + const propuestas = calcularPropuestas({ + unidad, + acabados, + apartado, + descuentos: descuentosDefault(desarrolloId), + desarrollo: desarrolloId, + }) + const propuesta = propuestas.find((p) => p.tipo === tipo) ?? propuestas[0] + if (!propuesta) throw new Error(`El desarrollo ${desarrolloId} no tiene propuestas`) + return { + unidad, + desarrolloId, + desarrolloNombre: params.nombre, + precioLista: obtenerPrecioLista(unidad, acabados), + supConstruccion: unidad.supConstruccion, + propuesta, + total: totalPagado(propuesta, apartado), + } + } + return { a: columna(unidadA), b: columna(unidadB) } +} diff --git a/apps/cotizador/src/lib/compartir.ts b/apps/cotizador/src/lib/compartir.ts new file mode 100644 index 0000000..b0914d4 --- /dev/null +++ b/apps/cotizador/src/lib/compartir.ts @@ -0,0 +1,62 @@ +import type { PropuestaResultado } from './calculations' +import { formatoMoneda, formatoFechaLarga } from './formatters' + +export interface DatosCompartir { + unidadCodigo: string + desarrolloNombre: string + propuesta: PropuestaResultado + apartado: number + vigencia: Date + /** Folio y URL pública solo existen si la cotización ya se guardó en el API */ + folio?: string + urlPublica?: string +} + +/** + * Texto resumen de la propuesta, listo para copiar/pegar en WhatsApp. + * Incluye unidad, desarrollo, propuesta elegida, montos y vigencia. + */ +export function textoCompartir(d: DatosCompartir): string { + const lineas: string[] = [] + lineas.push( + d.folio ? `Cotización ${d.folio} — FRASA Desarrollos` : 'Cotización — FRASA Desarrollos', + ) + lineas.push(`Desarrollo: ${d.desarrolloNombre}`) + lineas.push(`Unidad: ${d.unidadCodigo}`) + lineas.push(`Propuesta: ${d.propuesta.titulo}`) + lineas.push(`Precio final c/desc: ${formatoMoneda(d.propuesta.precioFinal)}`) + lineas.push(`Apartado: ${formatoMoneda(d.apartado)}`) + lineas.push(`Enganche: ${formatoMoneda(d.propuesta.enganche)}`) + if (d.propuesta.notaMensualidades) { + lineas.push(`Mensualidades: — (${d.propuesta.notaMensualidades})`) + } else if (d.propuesta.mensualidad !== null) { + lineas.push( + `Mensualidades: ${d.propuesta.numMensualidades} × ${formatoMoneda(d.propuesta.mensualidad)}`, + ) + } else { + lineas.push('Mensualidades: N/A') + } + lineas.push(`Pago final: ${formatoMoneda(d.propuesta.pagoFinal)}`) + lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`) + if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`) + return lineas.join('\n') +} + +/** + * Normaliza un teléfono a dígitos para wa.me; null si no es usable. + * Agrega 52 (México) cuando son 10 dígitos locales. + */ +export function telefonoWhatsApp(telefono?: string): string | null { + if (!telefono) return null + const digitos = telefono.replace(/\D/g, '') + if (digitos.length === 10) return `52${digitos}` + if (digitos.length >= 11 && digitos.length <= 15) return digitos + return null +} + +/** Deep link wa.me con el texto urlencoded; null si no hay teléfono válido */ +export function urlWhatsApp(telefono: string | undefined, texto: string): string | null { + const numero = telefonoWhatsApp(telefono) + if (!numero) return null + return `https://wa.me/${numero}?text=${encodeURIComponent(texto)}` +} diff --git a/apps/cotizador/src/lib/folio.ts b/apps/cotizador/src/lib/folio.ts new file mode 100644 index 0000000..f79fe35 --- /dev/null +++ b/apps/cotizador/src/lib/folio.ts @@ -0,0 +1,8 @@ +/** + * Folio corto de cotización, derivado de forma determinista del id (uuid). + * Espejo de api-gateway/src/utils/folio.ts (mantener sincronizados). + */ +export function folioCotizacion(id: string): string { + const hex = id.replace(/[^0-9a-fA-F]/g, '').toUpperCase() + return `COT-${(hex || id.toUpperCase()).slice(0, 6)}` +} diff --git a/apps/cotizador/src/lib/herramientas.test.ts b/apps/cotizador/src/lib/herramientas.test.ts new file mode 100644 index 0000000..1cbfd22 --- /dev/null +++ b/apps/cotizador/src/lib/herramientas.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { folioCotizacion } from './folio' +import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir' +import { compararUnidades } from './comparadorUnidades' +import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations' +import { UNIDADES_SEED } from '../data/unidades' +import { UNIDADES_ATELIER } from '../data/unidades-atelier' +import { formatoMoneda } from './formatters' + +describe('folioCotizacion', () => { + it('deriva COT-XXXXXX (6 hex mayúsculas) del uuid, igual que el API', () => { + expect(folioCotizacion('3f8a1b2c-9d4e-4f5a-b6c7-d8e9f0a1b2c3')).toBe('COT-3F8A1B') + expect(folioCotizacion('xyz')).toBe('COT-XYZ') + }) +}) + +describe('textoCompartir', () => { + const propuesta = calcularPropuestas({ + unidad: UNIDADES_SEED[0], + acabados: true, + apartado: APARTADO_DEFAULT, + descuentos: { ...DESCUENTOS_DEFAULT }, + numMeses: 14, + })[0] + + const base = { + unidadCodigo: 'U-101', + desarrolloNombre: 'Diagonal', + propuesta, + apartado: APARTADO_DEFAULT, + vigencia: new Date('2026-08-31T00:00:00'), + } + + it('incluye unidad, desarrollo, propuesta, montos y vigencia', () => { + const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' }) + expect(texto).toContain('Cotización COT-3F8A1B') + expect(texto).toContain('Desarrollo: Diagonal') + expect(texto).toContain('Unidad: U-101') + expect(texto).toContain(`Propuesta: ${propuesta.titulo}`) + expect(texto).toContain(`Enganche: ${formatoMoneda(propuesta.enganche)}`) + expect(texto).toContain(`Pago final: ${formatoMoneda(propuesta.pagoFinal)}`) + expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026') + expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico') + }) + + it('sin folio/URL usa encabezado genérico y omite la liga', () => { + const texto = textoCompartir(base) + expect(texto).toContain('Cotización — FRASA Desarrollos') + expect(texto).not.toContain('Consulta:') + }) + + it('contado (mensualidad null) muestra Mensualidades: N/A', () => { + const contado = calcularPropuestas({ + unidad: UNIDADES_SEED[0], + acabados: true, + apartado: APARTADO_DEFAULT, + descuentos: { ...DESCUENTOS_DEFAULT }, + numMeses: 14, + }).find((p) => p.tipo === 'CONTADO')! + expect(textoCompartir({ ...base, propuesta: contado })).toContain('Mensualidades: N/A') + }) +}) + +describe('telefonoWhatsApp / urlWhatsApp', () => { + it('10 dígitos locales → agrega 52', () => { + expect(telefonoWhatsApp('664 123 4567')).toBe('526641234567') + }) + + it('con lada internacional la conserva; basura → null', () => { + expect(telefonoWhatsApp('+1 (664) 123-4567')).toBe('16641234567') + expect(telefonoWhatsApp('12345')).toBeNull() + expect(telefonoWhatsApp(undefined)).toBeNull() + }) + + it('urlWhatsApp urlencodea el texto; null sin teléfono', () => { + const url = urlWhatsApp('6641234567', 'Hola\nlínea 2') + expect(url).toBe(`https://wa.me/526641234567?text=${encodeURIComponent('Hola\nlínea 2')}`) + expect(urlWhatsApp(undefined, 'Hola')).toBeNull() + }) +}) + +describe('compararUnidades', () => { + const diagonal = UNIDADES_SEED[0] // U-101 DIAGONAL + const atelier = UNIDADES_ATELIER[0] + + it('compara 2 unidades de distinto desarrollo con los parámetros de cada una', () => { + const r = compararUnidades(diagonal, atelier, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true) + expect(r.a.desarrolloId).toBe('DIAGONAL') + expect(r.b.desarrolloId).toBe('ATELIER') + expect(r.a.propuesta.tipo).toBe('CREDITO_BANCARIO') + expect(r.b.propuesta.tipo).toBe('CREDITO_BANCARIO') + // Precios de lista según acabados/precio único + expect(r.a.precioLista).toBe(diagonal.precioConAcabados) + expect(r.b.precioLista).toBe(atelier.precioConAcabados) + // Totales consistentes con el motor + expect(r.a.total).toBe(totalPagado(r.a.propuesta, APARTADO_DEFAULT)) + expect(r.b.total).toBe(totalPagado(r.b.propuesta, APARTADO_DEFAULT)) + }) + + it('expone m² de construcción y nombre de desarrollo para la tabla', () => { + const r = compararUnidades(diagonal, atelier, 'CONTADO', APARTADO_DEFAULT, false) + expect(r.a.supConstruccion).toBe(diagonal.supConstruccion) + expect(r.a.desarrolloNombre).toBe('Diagonal') + expect(r.a.propuesta.tipo).toBe('CONTADO') + // Sin acabados en DIAGONAL el precio de lista baja + expect(r.a.precioLista).toBe(diagonal.precioSinAcabados) + }) + + it('total null cuando el plazo no se puede conocer (sin fecha de entrega)', () => { + const sinFecha = { ...diagonal, fechaEntrega: null } + const r = compararUnidades(sinFecha, diagonal, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true) + expect(r.a.total).toBeNull() + expect(r.b.total).not.toBeNull() + }) +}) diff --git a/apps/cotizador/src/lib/pdf.test.ts b/apps/cotizador/src/lib/pdf.test.ts index 1b7c644..ce98772 100644 --- a/apps/cotizador/src/lib/pdf.test.ts +++ b/apps/cotizador/src/lib/pdf.test.ts @@ -29,6 +29,32 @@ describe('crearDocumentoPdf', () => { const texto = doc.output('datauristring') expect(texto.length).toBeGreaterThan(0) }) + + it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => { + const unidad = UNIDADES_SEED[0] + const propuestas = calcularPropuestas({ + unidad, + acabados: true, + apartado: APARTADO_DEFAULT, + descuentos: { ...DESCUENTOS_DEFAULT }, + numMeses: 14, + }) + // PNG 1x1 válido; en la app lo genera la librería qrcode + const png1x1 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + const { doc } = crearDocumentoPdf({ + unidad, + acabados: true, + apartado: APARTADO_DEFAULT, + precioLista: unidad.precioConAcabados, + propuestas, + cliente: { nombre: 'Juan Pérez' }, + folio: 'COT-3F8A1B', + qrDataUrl: png1x1, + vigencia: new Date('2026-08-31T00:00:00'), + }) + expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1) + }) }) describe('cuota de mantenimiento', () => { diff --git a/apps/cotizador/src/lib/pdf.ts b/apps/cotizador/src/lib/pdf.ts index 2235490..9633d41 100644 --- a/apps/cotizador/src/lib/pdf.ts +++ b/apps/cotizador/src/lib/pdf.ts @@ -14,6 +14,12 @@ export interface PdfInput { cliente: Cliente /** Parámetros del desarrollo; por defecto DIAGONAL */ desarrollo?: ParametrosDesarrollo + /** Folio corto (COT-XXXXXX); solo existe si la cotización se guardó en el API */ + folio?: string + /** Data URL PNG del QR que apunta al endpoint público de la cotización */ + qrDataUrl?: string + /** Fecha límite de vigencia que se imprime bajo el encabezado */ + vigencia?: Date } const formatoM2Pdf = (v: number | null): string => (v === null ? '—' : `${v} m2`) @@ -43,7 +49,7 @@ export interface DocumentoPdf { } /** Construye el documento PDF "ANEXO A" (sin descargarlo) */ -export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo }: PdfInput): DocumentoPdf { +export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf { const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT] const doc = new jsPDF() const hoy = new Date() @@ -75,7 +81,26 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro y += 5 doc.setFont('helvetica', 'bold') doc.text(`DESARROLLO ${params.nombre.toUpperCase()}`, 105, y, { align: 'center' }) - y += 8 + y += 6 + + // 1.1 Folio, vigencia y QR (cuando la cotización ya quedó guardada en el API) + if (folio) { + doc.setFontSize(9) + doc.setFont('helvetica', 'bold') + doc.text(`FOLIO: ${folio}`, 105, y, { align: 'center' }) + y += 5 + } + if (vigencia) { + doc.setFontSize(9) + doc.setFont('helvetica', 'normal') + doc.text(`VIGENTE HASTA: ${formatoFechaLarga(vigencia).toUpperCase()}`, 105, y, { align: 'center' }) + y += 5 + } + if (qrDataUrl) { + // Esquina superior derecha; el QR apunta al resumen público de la cotización + doc.addImage(qrDataUrl, 'PNG', 210 - MARGEN - 28, MARGEN, 28, 28) + } + y += 2 // 2. Tabla de unidad autoTable(doc, { diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index f55b1d6..88be1b8 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -1,16 +1,31 @@ +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' +import QRCode from 'qrcode' import { useUnidades } from '../hooks/useUnidades' import { useCotizacion } from '../hooks/useCotizacion' import { UnitSelector } from '../components/UnitSelector' import { UnitInfoCard } from '../components/UnitInfoCard' import { ProposalCard } from '../components/ProposalCard' import { ComparadorPropuestas } from '../components/ComparadorPropuestas' +import { ComparadorUnidades } from '../components/ComparadorUnidades' +import { MisCotizaciones } from '../components/MisCotizaciones' +import { ModalCliente } from '../components/ModalCliente' import { generarPdf } from '../lib/pdf' import { formatoMoneda, formatoFechaLarga } from '../lib/formatters' import { calcularVigencia } from '../lib/vigencia' +import { folioCotizacion } from '../lib/folio' +import { textoCompartir, urlWhatsApp } from '../lib/compartir' import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos' +import { + getMisCotizaciones, + guardarCotizacion, + urlPublicaCotizacion, + TIPO_A_API, + TIPO_DE_API, + type CotizacionGuardada, +} from '../services/api' import type { Cliente } from '../types' const esquemaCliente = z.object({ @@ -46,17 +61,50 @@ export default function CotizadorPage() { apartadoEfectivo, errorApartado, setDescuento, + propuestaIndex, + setPropuestaIndex, precioLista, numMeses, propuestas, + restaurar, + nueva, } = cotizacion const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo) const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length + // Historial del vendedor + última cotización guardada (para folio/QR/compartir) + const [misCotizaciones, setMisCotizaciones] = useState([]) + const [cargandoMias, setCargandoMias] = useState(true) + const [errorMias, setErrorMias] = useState(null) + const [ultimaGuardada, setUltimaGuardada] = useState(null) + const [modalClienteAbierto, setModalClienteAbierto] = useState(false) + const [guardando, setGuardando] = useState(false) + const [aviso, setAviso] = useState(null) + + useEffect(() => { + let vivo = true + getMisCotizaciones() + .then((lista) => { + if (vivo) setMisCotizaciones(lista) + }) + .catch((err) => { + console.warn('[api] No se pudo cargar el historial de cotizaciones.', err) + if (vivo) setErrorMias('No se pudo cargar el historial (sin conexión al servidor).') + }) + .finally(() => { + if (vivo) setCargandoMias(false) + }) + return () => { + vivo = false + } + }, []) + const { register, watch, + setValue, + reset, formState: { errors }, } = useForm({ resolver: zodResolver(esquemaCliente), @@ -65,16 +113,144 @@ export default function CotizadorPage() { }) const nombreCliente = watch('nombre') ?? '' - const puedeGenerarPdf = Boolean(unidad) && nombreCliente.trim().length >= 3 && !errorApartado + // La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo + const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] + const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando + + /** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */ + const guardarEnApi = async (cliente: Cliente): Promise => { + if (!unidad || !propuestaActiva) return null + try { + const guardada = await guardarCotizacion({ + unidadCodigo: unidad.codigo, + clienteNombre: cliente.nombre, + ...(cliente.email ? { clienteEmail: cliente.email } : {}), + ...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}), + tipoPropuesta: TIPO_A_API[propuestaActiva.tipo], + acabados, + apartado: apartadoEfectivo, + descuento: propuestaActiva.descuento, + }) + const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) } + setUltimaGuardada(conFolio) + setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50)) + return conFolio + } catch (err) { + console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err) + setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).') + return null + } + } + + const generarPdfConCliente = async (cliente: Cliente) => { + if (!unidad) return + setGuardando(true) + try { + const guardada = await guardarEnApi(cliente) + let folio: string | undefined + let qrDataUrl: string | undefined + if (guardada) { + folio = guardada.folio ?? folioCotizacion(guardada.id) + qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), { + margin: 1, + width: 200, + }) + } + generarPdf({ + unidad, + acabados, + apartado: apartadoEfectivo, + precioLista, + propuestas, + cliente, + desarrollo: params, + ...(folio ? { folio } : {}), + ...(qrDataUrl ? { qrDataUrl } : {}), + vigencia: calcularVigencia(), + }) + } finally { + setGuardando(false) + } + } const handleGenerarPdf = () => { if (!unidad) return - const cliente: Cliente = { + // Sin nombre capturado, pide los datos del cliente en el modal + if (nombreCliente.trim().length < 3) { + setModalClienteAbierto(true) + return + } + void generarPdfConCliente({ nombre: nombreCliente.trim(), email: watch('email') || undefined, telefono: watch('telefono') || undefined, + }) + } + + const handleModalAceptar = (cliente: Cliente) => { + setModalClienteAbierto(false) + setValue('nombre', cliente.nombre, { shouldValidate: true }) + if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true }) + if (cliente.email) setValue('email', cliente.email, { shouldValidate: true }) + void generarPdfConCliente(cliente) + } + + const handleCompartir = async () => { + if (!unidad || !propuestaActiva) return + const texto = textoCompartir({ + unidadCodigo: unidad.codigo, + desarrolloNombre: params.nombre, + propuesta: propuestaActiva, + apartado: apartadoEfectivo, + vigencia: calcularVigencia(), + ...(ultimaGuardada + ? { + folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id), + urlPublica: urlPublicaCotizacion(ultimaGuardada.id), + } + : {}), + }) + try { + await navigator.clipboard.writeText(texto) + setAviso('Resumen copiado al portapapeles.') + } catch { + setAviso('No se pudo copiar al portapapeles.') } - generarPdf({ unidad, acabados, apartado: apartadoEfectivo, precioLista, propuestas, cliente, desarrollo: params }) + const telefono = watch('telefono') || ultimaGuardada?.clienteTelefono || undefined + const url = urlWhatsApp(telefono, texto) + if (url) window.open(url, '_blank', 'noopener') + } + + const handleRetomar = (c: CotizacionGuardada) => { + const u = unidades.find( + (x) => x.codigo === c.unidadCodigo && (x.desarrollo ?? 'DIAGONAL') === c.desarrollo, + ) + if (!u) { + setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`) + return + } + const tipo = TIPO_DE_API[c.tipoPropuesta] + const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo) + restaurar({ + desarrollo: c.desarrollo, + unidad: u, + acabados: c.acabados, + apartado: c.apartado, + descuento: c.descuento, + propuestaIndex: idx >= 0 ? idx : 0, + }) + setValue('nombre', c.clienteNombre, { shouldValidate: true }) + setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true }) + setValue('email', c.clienteEmail ?? '', { shouldValidate: true }) + setUltimaGuardada(c) + setAviso(`Cotización ${c.folio ?? ''} retomada.`) + } + + const handleNueva = () => { + nueva() + reset({ nombre: '', email: '', telefono: '' }) + setUltimaGuardada(null) + setAviso(null) } return ( @@ -251,6 +427,16 @@ export default function CotizadorPage() {

)} + + {unidad && ( + + )} {unidad && } @@ -258,14 +444,21 @@ export default function CotizadorPage() { {/* Panel de propuestas */}
+ + {!unidad ? ( -
+

Selecciona una unidad para ver las propuestas comerciales.

) : ( - <> +
{propuestas.map((p, i) => ( p.titulo)} /> -
+
+
+ + +
+
- {!puedeGenerarPdf && ( + {aviso &&

{aviso}

} + {!puedeGenerarPdf && !guardando && (

- Selecciona una unidad y captura el nombre del cliente para generar el PDF. + Selecciona una unidad para guardar y generar el PDF.

)} - +
)} + +
+ + setModalClienteAbierto(false)} + /> ) } diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index 013cb26..e2b6eda 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -1,10 +1,12 @@ -import type { Unidad } from '../types' +import type { Unidad, TipoPropuesta } from '../types' import { INVENTARIO_SEED } from '../data/unidades' import { conAuth, manejar401 } from './auth' // TODO produccion: '/api' same-origin detras de NGINX const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' +export { API_URL } + export interface GetUnidadesResult { unidades: Unidad[] /** true cuando se usó el seed local por no poder contactar el API */ @@ -28,3 +30,79 @@ export async function getUnidades(): Promise { return { unidades: INVENTARIO_SEED, usandoSeed: true } } } + +// ---------- Cotizaciones guardadas (historial del vendedor) ---------- + +/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */ +export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' + +export const TIPO_A_API: Record = { + CREDITO_BANCARIO: 'BANCARIO', + CREDITO_INFONAVIT_COFINAVIT: 'INFONAVIT', + CONTADO: 'CONTADO', + SEMI_CONTADO: 'SEMI_CONTADO', +} + +export const TIPO_DE_API: Record = { + BANCARIO: 'CREDITO_BANCARIO', + INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT', + CONTADO: 'CONTADO', + SEMI_CONTADO: 'SEMI_CONTADO', +} + +export interface CotizacionGuardada { + id: string + folio?: string + unidadCodigo: string + desarrollo: string + clienteNombre: string + clienteTelefono: string | null + clienteEmail: string | null + tipoPropuesta: TipoPropuestaApi + acabados: boolean + apartado: number + descuento: number + precioLista: number + precioFinal: number + enganche: number + numMensualidades: number + mensualidad: number | null + pagoFinal: number + createdAt: string +} + +export interface GuardarCotizacionPayload { + unidadCodigo: string + clienteNombre: string + clienteEmail?: string + clienteTelefono?: string + tipoPropuesta: TipoPropuestaApi + acabados: boolean + apartado?: number + descuento?: number +} + +/** URL pública de una cotización (destino del QR del PDF) */ +export function urlPublicaCotizacion(id: string): string { + return `${API_URL}/cotizaciones/${id}/publico` +} + +/** POST /cotizaciones — persiste y registra la oportunidad en Monday */ +export async function guardarCotizacion(payload: GuardarCotizacionPayload): Promise { + const resp = await fetch(`${API_URL}/cotizaciones`, conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + })) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as CotizacionGuardada +} + +/** GET /cotizaciones/mias — últimas 50 del vendedor autenticado */ +export async function getMisCotizaciones(): Promise { + const resp = await fetch(`${API_URL}/cotizaciones/mias`, conAuth()) + manejar401(resp) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return (await resp.json()) as CotizacionGuardada[] +}