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.
+
+ ) : (
+
+
+
+
+ |
+ Concepto
+ |
+ {[comparacion.a, comparacion.b].map((c) => (
+
+ {c.unidad.codigo}
+ |
+ ))}
+
+
+
+ {filas.map((fila) => (
+
+ |
+ {fila.etiqueta}
+ |
+ {[comparacion.a, comparacion.b].map((c) => (
+
+ {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) => (
+ |
+ {h}
+ |
+ ),
+ )}
+
+
+
+ {cotizaciones.map((c) => (
+
+ |
+ {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 (
+
+
+
+ Datos del cliente
+
+
+ Se necesitan para guardar la cotización y generar el PDF.
+
+
+
+
+ {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[]
+}