From c77b4c06284e1931fc0d38d2f743160d0554d58d Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Mon, 7 Sep 2026 06:04:49 +0000 Subject: [PATCH] feat(mejoras): 14 mejoras al CRM Frasa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 1 - UI: dark mode, skeletons, toasts Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K Fase 3 - Datos: export Excel, notas apartados, auditoría Fase 4 - Dashboard: gráficas vendedor + métricas uso Fase 5 - Responsive: tablets optimizadas Fase 6 - PWA: IndexedDB offline, push notifications Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos 721 API tests + fronts verdes. --- api-gateway/package-lock.json | 95 +++ api-gateway/package.json | 2 + .../migration.sql | 26 + api-gateway/prisma/schema.prisma | 37 ++ api-gateway/src/config/index.ts | 5 + .../controllers/apartados.controller.test.ts | 3 + .../src/controllers/apartados.controller.ts | 2 + .../controllers/auditoria.controller.test.ts | 93 +++ .../src/controllers/auditoria.controller.ts | 31 + .../src/controllers/buscar.controller.test.ts | 67 +++ .../src/controllers/buscar.controller.ts | 13 + .../controllers/clientes.controller.test.ts | 5 +- .../src/controllers/clientes.controller.ts | 2 + .../controllers/cotizaciones-fallback.test.ts | 6 +- .../cotizaciones.controller.test.ts | 5 +- .../controllers/cotizaciones.controller.ts | 2 + .../src/controllers/leads.controller.test.ts | 7 + .../src/controllers/leads.controller.ts | 7 + .../src/controllers/notas.controller.test.ts | 69 +++ .../src/controllers/notas.controller.ts | 23 + .../oportunidades.controller.test.ts | 4 + .../controllers/oportunidades.controller.ts | 3 + .../src/controllers/push.controller.ts | 41 ++ .../reporte-mi-tablero.controller.test.ts | 139 +++++ .../reporte-mi-tablero.controller.ts | 52 ++ api-gateway/src/middleware/auth.middleware.ts | 1 + api-gateway/src/routes/index.ts | 29 + .../src/services/auditoria.service.test.ts | 95 +++ api-gateway/src/services/auditoria.service.ts | 49 ++ .../src/services/automatizaciones.service.ts | 43 ++ .../src/services/buscar.service.test.ts | 163 ++++++ api-gateway/src/services/buscar.service.ts | 111 ++++ .../services/nota-apartado.service.test.ts | 54 ++ .../src/services/nota-apartado.service.ts | 26 + api-gateway/src/services/push.service.test.ts | 140 +++++ api-gateway/src/services/push.service.ts | 87 +++ .../recordatorios-cobranza.service.ts | 5 + .../reporte-mi-tablero.service.test.ts | 147 +++++ .../services/reporte-mi-tablero.service.ts | 311 ++++++++++ apps/agente-ia/package-lock.json | 40 +- apps/agente-ia/package.json | 4 +- apps/agente-ia/src/components/AuthGate.tsx | 8 +- apps/agente-ia/src/components/Burbuja.tsx | 2 +- .../src/components/CambiarPasswordModal.tsx | 8 +- .../src/components/DarkModeToggle.tsx | 32 + apps/agente-ia/src/components/LoginScreen.tsx | 12 +- apps/agente-ia/src/components/Skeleton.tsx | 21 + apps/agente-ia/src/index.css | 2 + apps/agente-ia/src/main.tsx | 2 + apps/agente-ia/src/pages/ChatPage.tsx | 43 +- apps/alta-clientes/package-lock.json | 38 +- apps/alta-clientes/package.json | 2 + .../src/components/AltaAfluenciaForm.tsx | 12 +- .../src/components/AppHeader.tsx | 14 +- .../alta-clientes/src/components/AuthGate.tsx | 8 +- .../src/components/BotonOcrIne.tsx | 14 +- .../src/components/BusquedaLead.tsx | 16 +- .../src/components/CambiarPasswordModal.tsx | 12 +- apps/alta-clientes/src/components/Campos.tsx | 6 +- .../src/components/ChecklistExpediente.tsx | 6 +- .../src/components/DarkModeToggle.tsx | 32 + .../src/components/FileUploader.tsx | 18 +- .../src/components/LoginScreen.tsx | 14 +- .../src/components/SectionCard.tsx | 8 +- .../src/components/SignaturePad.tsx | 2 +- .../alta-clientes/src/components/Skeleton.tsx | 21 + apps/alta-clientes/src/components/Stepper.tsx | 10 +- apps/alta-clientes/src/forms/Navegacion.tsx | 2 +- apps/alta-clientes/src/index.css | 2 + apps/alta-clientes/src/main.tsx | 2 + .../src/pages/AltaClientePage.tsx | 68 ++- .../src/pages/FirmaRemotaPage.tsx | 30 +- apps/cobranza/package-lock.json | 37 ++ apps/cobranza/package.json | 2 + apps/cobranza/src/components/AuthGate.tsx | 8 +- .../src/components/CambiarPasswordModal.tsx | 10 +- .../src/components/DarkModeToggle.tsx | 32 + apps/cobranza/src/components/KpiCard.tsx | 8 +- apps/cobranza/src/components/Layout.tsx | 12 +- apps/cobranza/src/components/LoginScreen.tsx | 12 +- apps/cobranza/src/components/ModalAbono.tsx | 12 +- apps/cobranza/src/components/Skeleton.tsx | 21 + apps/cobranza/src/index.css | 2 + apps/cobranza/src/main.tsx | 2 + apps/cobranza/src/pages/DashboardPage.tsx | 39 +- apps/cobranza/src/pages/HoyPage.tsx | 13 +- apps/cobranza/src/pages/NuevoPlanPage.tsx | 22 +- apps/cobranza/src/pages/PlanDetallePage.tsx | 56 +- apps/cotizador/index.html | 1 + apps/cotizador/package-lock.json | 45 +- apps/cotizador/package.json | 3 + apps/cotizador/public/manifest.json | 12 + apps/cotizador/public/sw.js | 21 + apps/cotizador/src/components/AuthGate.tsx | 14 +- .../src/components/BuscadorCliente.tsx | 16 +- .../src/components/CambiarPasswordModal.tsx | 12 +- .../src/components/ComparadorPropuestas.tsx | 22 +- .../src/components/ComparadorUnidades.tsx | 26 +- .../src/components/DarkModeToggle.tsx | 32 + apps/cotizador/src/components/LoginScreen.tsx | 12 +- .../src/components/MisCotizaciones.tsx | 34 +- .../cotizador/src/components/ModalCliente.tsx | 12 +- .../cotizador/src/components/ProposalCard.tsx | 10 +- apps/cotizador/src/components/Skeleton.tsx | 21 + .../cotizador/src/components/UnitInfoCard.tsx | 12 +- .../cotizador/src/components/UnitSelector.tsx | 14 +- apps/cotizador/src/index.css | 2 + apps/cotizador/src/main.tsx | 2 + apps/cotizador/src/pages/CotizadorPage.tsx | 61 +- apps/cotizador/src/services/api.ts | 9 +- apps/cotizador/src/services/offline-db.ts | 33 ++ apps/dashboards/package-lock.json | 37 ++ apps/dashboards/package.json | 2 + apps/dashboards/src/components/AuthGate.tsx | 8 +- .../src/components/CambiarPasswordModal.tsx | 10 +- .../src/components/DarkModeToggle.tsx | 32 + apps/dashboards/src/components/KpiCard.tsx | 8 +- .../dashboards/src/components/LoginScreen.tsx | 12 +- apps/dashboards/src/components/MiTablero.tsx | 6 +- .../src/components/Perfilamiento.tsx | 4 +- .../src/components/SeccionEquipo.tsx | 12 +- apps/dashboards/src/components/Skeleton.tsx | 21 + .../src/components/TableroGeneral.tsx | 8 +- apps/dashboards/src/components/comunes.tsx | 10 +- apps/dashboards/src/index.css | 2 + apps/dashboards/src/main.tsx | 2 + apps/dashboards/src/pages/DashboardPage.tsx | 20 +- apps/landing/package-lock.json | 40 +- apps/landing/package.json | 4 +- apps/landing/src/App.tsx | 12 +- .../landing/src/components/CommandPalette.tsx | 208 +++++++ .../src/components/ConfiguracionCuenta.tsx | 28 +- .../landing/src/components/DarkModeToggle.tsx | 32 + apps/landing/src/components/ModalContacto.tsx | 32 +- apps/landing/src/components/Navbar.tsx | 79 ++- apps/landing/src/components/Skeleton.tsx | 21 + apps/landing/src/components/TarjetaModulo.tsx | 8 +- .../landing/src/components/VistaContactos.tsx | 115 +++- .../components/config/AuditoriaLog.test.tsx | 77 +++ .../src/components/config/AuditoriaLog.tsx | 177 ++++++ .../src/components/config/ConfigPage.tsx | 11 +- apps/landing/src/index.css | 2 + apps/landing/src/main.tsx | 2 + apps/landing/src/services/config.ts | 31 + apps/mi-dia/index.html | 1 + apps/mi-dia/package-lock.json | 546 +++++++++++++++++- apps/mi-dia/package.json | 7 +- apps/mi-dia/public/manifest.json | 12 + apps/mi-dia/public/sw.js | 21 + apps/mi-dia/src/components/AuthGate.tsx | 2 +- .../src/components/CambiarPasswordModal.tsx | 10 +- apps/mi-dia/src/components/DarkModeToggle.tsx | 32 + .../src/components/DashboardVendedor.test.tsx | 68 +++ .../src/components/DashboardVendedor.tsx | 220 +++++++ .../src/components/FiltrosAvanzados.tsx | 151 +++++ apps/mi-dia/src/components/KpiCard.tsx | 8 +- apps/mi-dia/src/components/LoginScreen.tsx | 14 +- .../src/components/NotasApartado.test.tsx | 99 ++++ apps/mi-dia/src/components/NotasApartado.tsx | 126 ++++ apps/mi-dia/src/components/Skeleton.tsx | 21 + apps/mi-dia/src/index.css | 2 + apps/mi-dia/src/main.tsx | 2 + apps/mi-dia/src/pages/MiDiaPage.tsx | 378 ++++++++---- apps/mi-dia/src/services/exportar.test.ts | 116 ++++ apps/mi-dia/src/services/exportar.ts | 70 +++ apps/mi-dia/src/services/notas.ts | 37 ++ apps/mi-dia/src/services/offline-db.test.ts | 51 ++ apps/mi-dia/src/services/offline-db.ts | 33 ++ apps/mi-dia/src/services/push.ts | 67 +++ .../mi-dia/src/services/reporte-mi-tablero.ts | 47 ++ 170 files changed, 5802 insertions(+), 637 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260907130000_nota_apartado_auditoria_log/migration.sql create mode 100644 api-gateway/src/controllers/auditoria.controller.test.ts create mode 100644 api-gateway/src/controllers/auditoria.controller.ts create mode 100644 api-gateway/src/controllers/buscar.controller.test.ts create mode 100644 api-gateway/src/controllers/buscar.controller.ts create mode 100644 api-gateway/src/controllers/notas.controller.test.ts create mode 100644 api-gateway/src/controllers/notas.controller.ts create mode 100644 api-gateway/src/controllers/push.controller.ts create mode 100644 api-gateway/src/controllers/reporte-mi-tablero.controller.test.ts create mode 100644 api-gateway/src/controllers/reporte-mi-tablero.controller.ts create mode 100644 api-gateway/src/services/auditoria.service.test.ts create mode 100644 api-gateway/src/services/auditoria.service.ts create mode 100644 api-gateway/src/services/buscar.service.test.ts create mode 100644 api-gateway/src/services/buscar.service.ts create mode 100644 api-gateway/src/services/nota-apartado.service.test.ts create mode 100644 api-gateway/src/services/nota-apartado.service.ts create mode 100644 api-gateway/src/services/push.service.test.ts create mode 100644 api-gateway/src/services/push.service.ts create mode 100644 api-gateway/src/services/reporte-mi-tablero.service.test.ts create mode 100644 api-gateway/src/services/reporte-mi-tablero.service.ts create mode 100644 apps/agente-ia/src/components/DarkModeToggle.tsx create mode 100644 apps/agente-ia/src/components/Skeleton.tsx create mode 100644 apps/alta-clientes/src/components/DarkModeToggle.tsx create mode 100644 apps/alta-clientes/src/components/Skeleton.tsx create mode 100644 apps/cobranza/src/components/DarkModeToggle.tsx create mode 100644 apps/cobranza/src/components/Skeleton.tsx create mode 100644 apps/cotizador/public/manifest.json create mode 100644 apps/cotizador/public/sw.js create mode 100644 apps/cotizador/src/components/DarkModeToggle.tsx create mode 100644 apps/cotizador/src/components/Skeleton.tsx create mode 100644 apps/cotizador/src/services/offline-db.ts create mode 100644 apps/dashboards/src/components/DarkModeToggle.tsx create mode 100644 apps/dashboards/src/components/Skeleton.tsx create mode 100644 apps/landing/src/components/CommandPalette.tsx create mode 100644 apps/landing/src/components/DarkModeToggle.tsx create mode 100644 apps/landing/src/components/Skeleton.tsx create mode 100644 apps/landing/src/components/config/AuditoriaLog.test.tsx create mode 100644 apps/landing/src/components/config/AuditoriaLog.tsx create mode 100644 apps/mi-dia/public/manifest.json create mode 100644 apps/mi-dia/public/sw.js create mode 100644 apps/mi-dia/src/components/DarkModeToggle.tsx create mode 100644 apps/mi-dia/src/components/DashboardVendedor.test.tsx create mode 100644 apps/mi-dia/src/components/DashboardVendedor.tsx create mode 100644 apps/mi-dia/src/components/FiltrosAvanzados.tsx create mode 100644 apps/mi-dia/src/components/NotasApartado.test.tsx create mode 100644 apps/mi-dia/src/components/NotasApartado.tsx create mode 100644 apps/mi-dia/src/components/Skeleton.tsx create mode 100644 apps/mi-dia/src/services/exportar.test.ts create mode 100644 apps/mi-dia/src/services/exportar.ts create mode 100644 apps/mi-dia/src/services/notas.ts create mode 100644 apps/mi-dia/src/services/offline-db.test.ts create mode 100644 apps/mi-dia/src/services/offline-db.ts create mode 100644 apps/mi-dia/src/services/push.ts create mode 100644 apps/mi-dia/src/services/reporte-mi-tablero.ts diff --git a/api-gateway/package-lock.json b/api-gateway/package-lock.json index 0bce4f8..0efcbc8 100644 --- a/api-gateway/package-lock.json +++ b/api-gateway/package-lock.json @@ -16,6 +16,7 @@ "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "node-cron": "^4.6.0", + "web-push": "^3.6.7", "zod": "^3.25.76" }, "devDependencies": { @@ -26,6 +27,7 @@ "@types/multer": "^2.0.0", "@types/node": "^22.15.0", "@types/node-cron": "^3.0.11", + "@types/web-push": "^3.6.4", "prisma": "^6.14.0", "tsx": "^4.20.3", "typescript": "^5.9.2", @@ -1118,6 +1120,16 @@ "@types/node": "*" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1246,12 +1258,33 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1271,6 +1304,12 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -2040,6 +2079,15 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2060,6 +2108,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -2275,6 +2336,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3369,6 +3445,25 @@ "dev": true, "license": "MIT" }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "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", diff --git a/api-gateway/package.json b/api-gateway/package.json index 15dfcea..4930c33 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -30,6 +30,7 @@ "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "node-cron": "^4.6.0", + "web-push": "^3.6.7", "zod": "^3.25.76" }, "devDependencies": { @@ -40,6 +41,7 @@ "@types/multer": "^2.0.0", "@types/node": "^22.15.0", "@types/node-cron": "^3.0.11", + "@types/web-push": "^3.6.4", "prisma": "^6.14.0", "tsx": "^4.20.3", "typescript": "^5.9.2", diff --git a/api-gateway/prisma/migrations/20260907130000_nota_apartado_auditoria_log/migration.sql b/api-gateway/prisma/migrations/20260907130000_nota_apartado_auditoria_log/migration.sql new file mode 100644 index 0000000..08f29cb --- /dev/null +++ b/api-gateway/prisma/migrations/20260907130000_nota_apartado_auditoria_log/migration.sql @@ -0,0 +1,26 @@ +-- Notas de apartado y bitácora de auditoría. +-- Patrón manual: db execute + migrate resolve. NUNCA migrate dev. + +-- CreateTable NotaApartado +CREATE TABLE IF NOT EXISTS "NotaApartado" ( + "id" TEXT NOT NULL PRIMARY KEY, + "apartadoId" TEXT NOT NULL, + "usuarioId" TEXT NOT NULL, + "contenido" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS "NotaApartado_apartadoId_idx" ON "NotaApartado"("apartadoId"); + +-- CreateTable AuditoriaLog +CREATE TABLE IF NOT EXISTS "AuditoriaLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "usuarioId" TEXT NOT NULL, + "accion" TEXT NOT NULL, + "entidad" TEXT NOT NULL, + "entidadId" TEXT NOT NULL, + "detalle" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS "AuditoriaLog_usuarioId_idx" ON "AuditoriaLog"("usuarioId"); +CREATE INDEX IF NOT EXISTS "AuditoriaLog_entidad_entidadId_idx" ON "AuditoriaLog"("entidad", "entidadId"); +CREATE INDEX IF NOT EXISTS "AuditoriaLog_createdAt_idx" ON "AuditoriaLog"("createdAt"); diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 40eb333..94256c5 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -387,3 +387,40 @@ model AsignacionLocal { @@unique([leadCapturaId]) @@index([usuarioId]) } + +model NotaApartado { + id String @id @default(uuid()) + apartadoId String + usuarioId String + contenido String + createdAt DateTime @default(now()) + + @@index([apartadoId]) +} + +model AuditoriaLog { + id String @id @default(uuid()) + usuarioId String + accion String + entidad String + entidadId String + detalle String? + createdAt DateTime @default(now()) + + @@index([usuarioId]) + @@index([entidad, entidadId]) + @@index([createdAt]) +} + +// Suscripciones push de los navegadores (Web Push API) +model PushSubscription { + id String @id @default(uuid()) + usuarioId String + endpoint String + p256dh String + auth String + createdAt DateTime @default(now()) + + @@unique([usuarioId]) + @@index([usuarioId]) +} diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index 57aa94c..8f4b575 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -91,6 +91,11 @@ export const config = { // aceptada por jsonwebtoken, p. ej. '72h') expiresIn: env('LIGA_REMOTA_EXP', '72h'), }, + push: { + vapidPublicKey: env('VAPID_PUBLIC_KEY', ''), + vapidPrivateKey: env('VAPID_PRIVATE_KEY', ''), + vapidEmail: env('VAPID_EMAIL', 'mailto:admin@frasa.com'), + }, } as const export type AppConfig = typeof config diff --git a/api-gateway/src/controllers/apartados.controller.test.ts b/api-gateway/src/controllers/apartados.controller.test.ts index bf8a3fe..f79f122 100644 --- a/api-gateway/src/controllers/apartados.controller.test.ts +++ b/api-gateway/src/controllers/apartados.controller.test.ts @@ -28,6 +28,9 @@ const prismaMock = vi.hoisted(() => ({ syncPendiente: { create: vi.fn(), }, + auditoriaLog: { + create: vi.fn().mockResolvedValue({}), + }, $transaction: vi.fn(), })) diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index 943a42f..ab745ec 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -12,6 +12,7 @@ import { saludMonday } from '../services/salud-monday.service.js' import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' import { esFalloMonday } from '../services/sync-monday.service.js' import { checkboxParaTipoDocumento } from './clientes.controller.js' +import * as auditoria from '../services/auditoria.service.js' /** * Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede @@ -76,6 +77,7 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response) } } res.json({ itemId, estado: body.estado }) + auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'apartado', itemId, body.estado) } // ---------- Precarga de documentos del expediente (desde "Mis apartados") ---------- diff --git a/api-gateway/src/controllers/auditoria.controller.test.ts b/api-gateway/src/controllers/auditoria.controller.test.ts new file mode 100644 index 0000000..bc0a3f1 --- /dev/null +++ b/api-gateway/src/controllers/auditoria.controller.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { AppError } from '../utils/errors.js' + +// ---------- Prisma mockeado ---------- + +const prismaMock = vi.hoisted(() => ({ + auditoriaLog: { + findMany: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +import { listarAuditoriaEndpoint } from './auditoria.controller.js' + +function reqFake(query?: Record, token?: { sub: string; rol: string }): Request { + return { query, usuarioToken: token } as unknown as Request +} + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => { + it('retorna logs de auditoría para admin', async () => { + const logsFake = [{ id: 'log-1', usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'l1', detalle: null, createdAt: new Date() }] + prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake) + const { res, json } = resFake() + + await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res) + expect(json).toHaveBeenCalledWith(logsFake) + }) + + it('aplica filtros de entidad y usuarioId', async () => { + prismaMock.auditoriaLog.findMany.mockResolvedValue([]) + const { res } = resFake() + + await listarAuditoriaEndpoint( + reqFake({ entidad: 'lead', usuarioId: 'u1' }, { sub: 'admin1', rol: 'admin' }), + res, + ) + expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ entidad: 'lead', usuarioId: 'u1' }), + }), + ) + }) + + it('aplica filtro de fechas', async () => { + prismaMock.auditoriaLog.findMany.mockResolvedValue([]) + const { res } = resFake() + + await listarAuditoriaEndpoint( + reqFake({ desde: '2026-09-01', hasta: '2026-09-07' }, { sub: 'admin1', rol: 'admin' }), + res, + ) + expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }), + }), + }), + ) + }) + + it('respeta el límite personalizado', async () => { + prismaMock.auditoriaLog.findMany.mockResolvedValue([]) + const { res } = resFake() + + await listarAuditoriaEndpoint( + reqFake({ limite: '10' }, { sub: 'admin1', rol: 'admin' }), + res, + ) + expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 10 }), + ) + }) + + it('retorna array vacío si no hay logs', async () => { + prismaMock.auditoriaLog.findMany.mockResolvedValue([]) + const { res, json } = resFake() + + await listarAuditoriaEndpoint(reqFake({}, { sub: 'admin1', rol: 'admin' }), res) + expect(json).toHaveBeenCalledWith([]) + }) +}) diff --git a/api-gateway/src/controllers/auditoria.controller.ts b/api-gateway/src/controllers/auditoria.controller.ts new file mode 100644 index 0000000..c07c771 --- /dev/null +++ b/api-gateway/src/controllers/auditoria.controller.ts @@ -0,0 +1,31 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' +import { buscar } from '../services/auditoria.service.js' + +const buscarSchema = z.object({ + entidad: z.string().optional(), + entidadId: z.string().optional(), + usuarioId: z.string().optional(), + desde: z.string().optional(), + hasta: z.string().optional(), + limite: z.coerce.number().int().min(1).max(500).optional(), +}) + +/** GET /api/auditoria — admin only: listar logs de auditoría con filtros */ +export async function listarAuditoriaEndpoint(req: Request, res: Response): Promise { + const query = buscarSchema.parse(req.query) + const filtros: { + entidad?: string + entidadId?: string + usuarioId?: string + desde?: Date + hasta?: Date + } = {} + if (query.entidad !== undefined) filtros.entidad = query.entidad + if (query.entidadId !== undefined) filtros.entidadId = query.entidadId + if (query.usuarioId !== undefined) filtros.usuarioId = query.usuarioId + if (query.desde !== undefined) filtros.desde = new Date(query.desde) + if (query.hasta !== undefined) filtros.hasta = new Date(query.hasta) + const logs = await buscar(filtros, query.limite ?? 100) + res.json(logs) +} diff --git a/api-gateway/src/controllers/buscar.controller.test.ts b/api-gateway/src/controllers/buscar.controller.test.ts new file mode 100644 index 0000000..ef081db --- /dev/null +++ b/api-gateway/src/controllers/buscar.controller.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' + +// ---------- Service mockeado ---------- + +const buscarMock = vi.hoisted(() => vi.fn()) + +vi.mock('../services/buscar.service.js', () => ({ buscar: buscarMock })) + +import { buscarEndpoint } from './buscar.controller.js' + +function req(query: Record = {}): Request { + return { query } as unknown as Request +} +function res(): Response { + const json = vi.fn() + return { json } as unknown as Response +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('buscarEndpoint (GET /api/buscar)', () => { + it('extrae q del query string y llama buscar', async () => { + buscarMock.mockResolvedValue([]) + const r = res() + await buscarEndpoint(req({ q: 'María' }), r) + expect(buscarMock).toHaveBeenCalledWith('María', 20) + expect(r.json).toHaveBeenCalledWith([]) + }) + + it('q vacío (no string) → termino vacío', async () => { + buscarMock.mockResolvedValue([]) + const r = res() + await buscarEndpoint(req({}), r) + expect(buscarMock).toHaveBeenCalledWith('', 20) + }) + + it('limite válido del query string', async () => { + buscarMock.mockResolvedValue([]) + const r = res() + await buscarEndpoint(req({ q: 'x', limite: '10' }), r) + expect(buscarMock).toHaveBeenCalledWith('x', 10) + }) + + it('limite inválido o > 50 se clampa a 20', async () => { + buscarMock.mockResolvedValue([]) + const r = res() + await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r) + expect(buscarMock).toHaveBeenCalledWith('x', 20) + + const r2 = res() + await buscarEndpoint(req({ q: 'x', limite: '100' }), r2) + expect(buscarMock).toHaveBeenCalledWith('x', 50) + }) + + it('devuelve los resultados del service como JSON', async () => { + const fakeResults = [ + { tipo: 'lead', id: 'l1', nombre: 'Test', detalle: '', url: '/app/#leads' }, + ] + buscarMock.mockResolvedValue(fakeResults) + const r = res() + await buscarEndpoint(req({ q: 'Test' }), r) + expect(r.json).toHaveBeenCalledWith(fakeResults) + }) +}) diff --git a/api-gateway/src/controllers/buscar.controller.ts b/api-gateway/src/controllers/buscar.controller.ts new file mode 100644 index 0000000..39245ec --- /dev/null +++ b/api-gateway/src/controllers/buscar.controller.ts @@ -0,0 +1,13 @@ +import type { Request, Response } from 'express' +import { buscar } from '../services/buscar.service.js' + +/** + * GET /api/buscar?q=... — búsqueda global (leads, contactos, cotizaciones, + * unidades). Requiere autenticación; devuelve array de BuscadoItem[]. + */ +export async function buscarEndpoint(req: Request, res: Response): Promise { + const q = typeof req.query.q === 'string' ? req.query.q : '' + const limite = typeof req.query.limite === 'string' ? Math.min(parseInt(req.query.limite, 10) || 20, 50) : 20 + const resultados = await buscar(q, limite) + res.json(resultados) +} diff --git a/api-gateway/src/controllers/clientes.controller.test.ts b/api-gateway/src/controllers/clientes.controller.test.ts index 5b29c80..6d9084a 100644 --- a/api-gateway/src/controllers/clientes.controller.test.ts +++ b/api-gateway/src/controllers/clientes.controller.test.ts @@ -17,9 +17,12 @@ const prismaMock = vi.hoisted(() => ({ usuario: { findUnique: vi.fn(), }, - syncPendiente: { + syncPendiente: { create: vi.fn(), }, + auditoriaLog: { + create: vi.fn().mockResolvedValue({}), + }, })) const mondayMock = vi.hoisted(() => ({ diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index 45ee302..34f61f7 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -13,6 +13,7 @@ import { encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' import { saludMonday } from '../services/salud-monday.service.js' import { esFalloMonday } from '../services/sync-monday.service.js' import { NotFoundError, BadRequestError } from '../utils/errors.js' +import * as auditoria from '../services/auditoria.service.js' // ---------- Alta de clientes ---------- @@ -420,6 +421,7 @@ export async function crearCliente(req: Request, res: Response): Promise { } res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId }) + auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_CLIENTE', 'cliente', cliente.id) } // ---------- Alta progresiva (guardado etapa por etapa del wizard) ---------- diff --git a/api-gateway/src/controllers/cotizaciones-fallback.test.ts b/api-gateway/src/controllers/cotizaciones-fallback.test.ts index f6e4ff5..6857ab1 100644 --- a/api-gateway/src/controllers/cotizaciones-fallback.test.ts +++ b/api-gateway/src/controllers/cotizaciones-fallback.test.ts @@ -12,7 +12,7 @@ const prismaMock = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), }, - syncPendiente: { + syncPendiente: { create: vi.fn(), }, })) @@ -30,6 +30,10 @@ const funnelMock = vi.hoisted(() => ({ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock })) +vi.mock('../services/auditoria.service.js', () => ({ + registrar: vi.fn(), + buscar: vi.fn(), +})) import { crearCotizacion } from './cotizaciones.controller.js' import { saludMonday } from '../services/salud-monday.service.js' diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts index a9018f3..b01ec6c 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -8,9 +8,12 @@ const prismaMock = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), }, - syncPendiente: { + syncPendiente: { create: vi.fn(), }, + auditoriaLog: { + create: vi.fn().mockResolvedValue({}), + }, })) const mondayMock = vi.hoisted(() => ({ diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index 0ce6573..615a263 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -11,6 +11,7 @@ import { import { prisma } from '../services/prisma.js' import { encolarSyncSeguro } from '../services/crm-local.service.js' import { NotFoundError } from '../utils/errors.js' +import * as auditoria from '../services/auditoria.service.js' import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js' const crearCotizacionSchema = z.object({ @@ -130,6 +131,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise ({ cliente: { findMany: vi.fn(), }, + auditoriaLog: { + create: vi.fn().mockResolvedValue({}), + }, $transaction: vi.fn(), })) @@ -473,6 +476,7 @@ describe('descartarLead (POST /api/leads/:itemId/descartar)', () => { describe('crearLeadEndpoint (POST /api/leads)', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) // El singleton Monday responde como si creara el lead en el tablero Leads vi.spyOn(mondayService, 'buscarLeadPorTelefono').mockResolvedValue(null) vi.spyOn(mondayService, 'crearLead').mockResolvedValue('lead-nuevo-9') @@ -569,6 +573,7 @@ describe('crearLeadEndpoint (POST /api/leads)', () => { describe('crearAfluenciaEndpoint (POST /api/afluencias)', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) // El singleton Monday responde como si creara la afluencia en Oportunidades vi.spyOn(mondayService, 'buscarOportunidadPorTelefono').mockResolvedValue(null) vi.spyOn(mondayService, 'crearOportunidadAfluencia').mockResolvedValue('op-nueva-9') @@ -747,6 +752,7 @@ describe('convertirLead (POST /api/leads/:itemId/convertir) — botón "Agendo" beforeEach(() => { vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) prismaMock.leadOportunidad.findUnique.mockResolvedValue(null) prismaMock.leadOportunidad.create.mockResolvedValue({}) prismaMock.automatizacionState.upsert.mockResolvedValue({}) @@ -827,6 +833,7 @@ describe('ruteo modo local (Monday caído o fallback 502)', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) prismaMock.leadCaptura.findFirst.mockResolvedValue(null) // sin duplicados locales prismaMock.leadCaptura.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.findMany.mockResolvedValue([]) diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 7a533a0..3bf1e76 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -7,6 +7,7 @@ import { automatizacionesService, CLAVE_RR_SKIP, prismaStateStore } from '../ser import { prisma } from '../services/prisma.js' import { saludMonday } from '../services/salud-monday.service.js' import { crmLocal, encolarSyncSeguro, esIdLocal } from '../services/crm-local.service.js' +import * as auditoria from '../services/auditoria.service.js' import { esFalloMonday } from '../services/sync-monday.service.js' import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js' import { AppError } from '../utils/errors.js' @@ -500,6 +501,7 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise ({ + notaApartado: { + create: vi.fn(), + findMany: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +import { crearNotaEndpoint, listarNotasEndpoint } from './notas.controller.js' + +function reqFake(params: Record, body?: unknown, token?: { sub: string }): Request { + return { params, body, usuarioToken: token } as unknown as Request +} + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('crearNotaEndpoint (POST /api/apartados/:id/notas)', () => { + it('crea una nota y retorna 201', async () => { + const notaFake = { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Nota test', createdAt: new Date() } + prismaMock.notaApartado.create.mockResolvedValue(notaFake) + const { res, status, json } = resFake() + + await crearNotaEndpoint(reqFake({ id: 'apt-1' }, { contenido: 'Nota test' }, { sub: 'u1' }), res) + expect(status).toHaveBeenCalledWith(201) + expect(json).toHaveBeenCalledWith(notaFake) + }) + + it('rechaza contenido vacío', async () => { + const { res } = resFake() + await expect( + crearNotaEndpoint(reqFake({ id: 'apt-1' }, { contenido: '' }, { sub: 'u1' }), res), + ).rejects.toThrow() + }) +}) + +describe('listarNotasEndpoint (GET /api/apartados/:id/notas)', () => { + it('retorna las notas del apartado', async () => { + const notasFake = [ + { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'A', createdAt: new Date() }, + ] + prismaMock.notaApartado.findMany.mockResolvedValue(notasFake) + const { res, json } = resFake() + + await listarNotasEndpoint(reqFake({ id: 'apt-1' }), res) + expect(json).toHaveBeenCalledWith(notasFake) + }) + + it('retorna array vacío si no hay notas', async () => { + prismaMock.notaApartado.findMany.mockResolvedValue([]) + const { res, json } = resFake() + + await listarNotasEndpoint(reqFake({ id: 'apt-inexistente' }), res) + expect(json).toHaveBeenCalledWith([]) + }) +}) diff --git a/api-gateway/src/controllers/notas.controller.ts b/api-gateway/src/controllers/notas.controller.ts new file mode 100644 index 0000000..038bc99 --- /dev/null +++ b/api-gateway/src/controllers/notas.controller.ts @@ -0,0 +1,23 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' +import { crearNota, listarNotas } from '../services/nota-apartado.service.js' + +const crearNotaSchema = z.object({ + contenido: z.string().trim().min(1, 'El contenido es obligatorio'), +}) + +/** POST /api/apartados/:id/notas — crear nota del apartado */ +export async function crearNotaEndpoint(req: Request, res: Response): Promise { + const body = crearNotaSchema.parse(req.body) + const apartadoId = String(req.params['id']) + const usuarioId = req.usuarioToken?.sub ?? 'desconocido' + const nota = await crearNota(apartadoId, usuarioId, body.contenido) + res.status(201).json(nota) +} + +/** GET /api/apartados/:id/notas — listar notas del apartado */ +export async function listarNotasEndpoint(req: Request, res: Response): Promise { + const apartadoId = String(req.params['id']) + const notas = await listarNotas(apartadoId) + res.json(notas) +} diff --git a/api-gateway/src/controllers/oportunidades.controller.test.ts b/api-gateway/src/controllers/oportunidades.controller.test.ts index c3dd2c1..3478121 100644 --- a/api-gateway/src/controllers/oportunidades.controller.test.ts +++ b/api-gateway/src/controllers/oportunidades.controller.test.ts @@ -26,6 +26,9 @@ const prismaMock = vi.hoisted(() => ({ findUnique: vi.fn(), upsert: vi.fn(), }, + auditoriaLog: { + create: vi.fn().mockResolvedValue({}), + }, $transaction: vi.fn(), })) @@ -170,6 +173,7 @@ describe('ruteo modo local (endpoints de oportunidades)', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.auditoriaLog.create.mockResolvedValue({}) prismaMock.leadCaptura.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) diff --git a/api-gateway/src/controllers/oportunidades.controller.ts b/api-gateway/src/controllers/oportunidades.controller.ts index bbbd5e1..41b719b 100644 --- a/api-gateway/src/controllers/oportunidades.controller.ts +++ b/api-gateway/src/controllers/oportunidades.controller.ts @@ -7,6 +7,7 @@ import { automatizacionesService } from '../services/automatizaciones.service.js import { saludMonday } from '../services/salud-monday.service.js' import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import { esFalloMonday } from '../services/sync-monday.service.js' +import * as auditoria from '../services/auditoria.service.js' /** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */ export const MOTIVOS_NO_COMPRA = [ @@ -96,6 +97,7 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr } } res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId }) + auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'PERDER_OPORTUNIDAD', 'oportunidad', mondayItemId, body.motivo) } // ---------- Cambio de etapa desde Mi día ---------- @@ -200,4 +202,5 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons } } res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) }) + auditoria.registrar(usuarioSub ?? 'sistema', 'CAMBIAR_ETAPA', 'oportunidad', itemId, body.etapa) } diff --git a/api-gateway/src/controllers/push.controller.ts b/api-gateway/src/controllers/push.controller.ts new file mode 100644 index 0000000..2ccc462 --- /dev/null +++ b/api-gateway/src/controllers/push.controller.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' +import { + registrarSuscripcion, + eliminarSuscripcion, + obtenerVapidPublicKey, +} from '../services/push.service.js' + +const suscripcionSchema = z.object({ + endpoint: z.string().url(), + p256dh: z.string().min(1), + auth: z.string().min(1), +}) + +/** POST /api/push/suscribir — registrar suscripción push del navegador */ +export async function suscribirEndpoint(req: Request, res: Response): Promise { + const usuarioId = req.usuarioToken?.sub + if (!usuarioId) { + res.status(401).json({ error: 'Token requerido' }) + return + } + const body = suscripcionSchema.parse(req.body) + await registrarSuscripcion(usuarioId, body) + res.json({ ok: true }) +} + +/** POST /api/push/desuscribir — eliminar suscripción push */ +export async function desuscribirEndpoint(req: Request, res: Response): Promise { + const usuarioId = req.usuarioToken?.sub + if (!usuarioId) { + res.status(401).json({ error: 'Token requerido' }) + return + } + await eliminarSuscripcion(usuarioId) + res.json({ ok: true }) +} + +/** GET /api/push/vapid-key — devuelve la VAPID public key (pública) */ +export function vapidKeyEndpoint(_req: Request, res: Response): void { + res.json({ publicKey: obtenerVapidPublicKey() }) +} diff --git a/api-gateway/src/controllers/reporte-mi-tablero.controller.test.ts b/api-gateway/src/controllers/reporte-mi-tablero.controller.test.ts new file mode 100644 index 0000000..9e6fee8 --- /dev/null +++ b/api-gateway/src/controllers/reporte-mi-tablero.controller.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { Request, Response } from 'express' +import { AppError } from '../utils/errors.js' + +// ---------- Mocks ---------- + +const prismaMock = vi.hoisted(() => ({ + usuario: { + findUnique: vi.fn(), + }, +})) + +vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) + +// Mock del servicio: permitimos que construirMiTablero funcione real; solo +// mockeamos obtenerMiTablero y obtenerMetricasUso para no tocar la BD +const servicioMock = vi.hoisted(() => ({ + obtenerMiTablero: vi.fn(), + obtenerMetricasUso: vi.fn(), +})) + +vi.mock('../services/reporte-mi-tablero.service.js', () => servicioMock) + +import { miTableroEndpoint, metricasUsoEndpoint, requireAdminOrCoordinador } from './reporte-mi-tablero.controller.js' + +function resFake() { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { json, status } as unknown as Response, status, json } +} + +const usuarioFake = { + id: 'u-1', + nombre: 'Vendedor Test', + rol: 'vendedor', + activo: true, + mondayUserId: 100, +} + +const tableroFake = { + generadoEn: '2026-09-07T12:00:00.000Z', + leadsPorDia: [], + conversion: { leads: 5, oportunidades: 3, apartados: 1, vendidos: 1 }, + ventasMes: { monto: 850000, unidades: 1 }, + porOrigen: [], + porDesarrollo: [], + actividadesHoy: 2, + actividadesPendientes: 5, +} + +const metricasFake = { + generadoEn: '2026-09-07T12:00:00.000Z', + usuarios: [], +} + +// ---------- miTableroEndpoint ---------- + +describe('miTableroEndpoint', () => { + beforeEach(() => { + vi.clearAllMocks() + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake) + servicioMock.obtenerMiTablero.mockResolvedValue(tableroFake) + }) + + it('devuelve el tablero del vendedor logueado', async () => { + const { res, json } = resFake() + const req = { usuarioToken: { sub: 'u-1' } } as unknown as Request + await miTableroEndpoint(req, res) + expect(json).toHaveBeenCalledWith(tableroFake) + expect(servicioMock.obtenerMiTablero).toHaveBeenCalledWith('u-1') + }) + + it('lanza 401 si el usuario no existe', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(null) + const req = { usuarioToken: { sub: 'u-unknown' } } as unknown as Request + const { res } = resFake() + await expect(miTableroEndpoint(req, res)).rejects.toThrow(AppError) + }) + + it('lanza 401 si el usuario está inactivo', async () => { + prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, activo: false }) + const req = { usuarioToken: { sub: 'u-1' } } as unknown as Request + const { res } = resFake() + await expect(miTableroEndpoint(req, res)).rejects.toThrow(AppError) + }) +}) + +// ---------- metricasUsoEndpoint ---------- + +describe('metricasUsoEndpoint', () => { + beforeEach(() => { + vi.clearAllMocks() + servicioMock.obtenerMetricasUso.mockResolvedValue(metricasFake) + }) + + it('devuelve métricas generales para admin', async () => { + const { res, json } = resFake() + const req = { usuarioToken: { sub: 'u-admin', rol: 'admin' } } as unknown as Request + await metricasUsoEndpoint(req, res) + expect(json).toHaveBeenCalledWith(metricasFake) + // Admin no filtra por usuario + expect(servicioMock.obtenerMetricasUso).toHaveBeenCalledWith(undefined) + }) + + it('filtra por usuarioId para coordinador', async () => { + const { res, json } = resFake() + const req = { usuarioToken: { sub: 'u-coord', rol: 'coordinador' } } as unknown as Request + await metricasUsoEndpoint(req, res) + expect(servicioMock.obtenerMetricasUso).toHaveBeenCalledWith('u-coord') + }) +}) + +// ---------- requireAdminOrCoordinador ---------- + +describe('requireAdminOrCoordinador', () => { + it('llama next() para admin', () => { + const next = vi.fn() + const req = { usuarioToken: { rol: 'admin' } } as unknown as Request + requireAdminOrCoordinador(req, {} as Response, next) + expect(next).toHaveBeenCalled() + }) + + it('llama next() para coordinador', () => { + const next = vi.fn() + const req = { usuarioToken: { rol: 'coordinador' } } as unknown as Request + requireAdminOrCoordinador(req, {} as Response, next) + expect(next).toHaveBeenCalled() + }) + + it('lanza 403 para vendedor', () => { + const req = { usuarioToken: { rol: 'vendedor' } } as unknown as Request + expect(() => requireAdminOrCoordinador(req, {} as Response, vi.fn())).toThrow(AppError) + }) + + it('lanza 403 para corporativo', () => { + const req = { usuarioToken: { rol: 'corporativo' } } as unknown as Request + expect(() => requireAdminOrCoordinador(req, {} as Response, vi.fn())).toThrow(AppError) + }) +}) diff --git a/api-gateway/src/controllers/reporte-mi-tablero.controller.ts b/api-gateway/src/controllers/reporte-mi-tablero.controller.ts new file mode 100644 index 0000000..e301f8d --- /dev/null +++ b/api-gateway/src/controllers/reporte-mi-tablero.controller.ts @@ -0,0 +1,52 @@ +import type { NextFunction, Request, Response } from 'express' +import { obtenerMiTablero, obtenerMetricasUso } from '../services/reporte-mi-tablero.service.js' +import { prisma } from '../services/prisma.js' +import { AppError } from '../utils/errors.js' + +// ---------- GET /api/reportes/mi-tablero ---------- + +/** + * Tablero personal del vendedor logueado: leads por día (30 días), conversión, + * ventas del mes, rendimiento por origen, ranking de desarrollos y actividades. + */ +export async function miTableroEndpoint(req: Request, res: Response): Promise { + const sub = req.usuarioToken?.sub + const usuario = sub + ? await prisma.usuario.findUnique({ where: { id: sub } }) + : await prisma.usuario.findUnique({ where: { username: 'admin' } }) + if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') + + const tablero = await obtenerMiTablero(usuario.id) + res.json(tablero) +} + +// ---------- GET /api/reportes/metricas-uso ---------- + +/** Middleware: admin o coordinador */ +export function requireAdminOrCoordinador( + req: Request, + _res: Response, + next: NextFunction, +): void { + const rol = req.usuarioToken?.rol + if (rol !== 'admin' && rol !== 'coordinador') { + throw new AppError(403, 'Se requiere rol admin o coordinador') + } + next() +} + +/** + * Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de + * descarte, actividades completadas vs pendientes. + * Admin ve todos; coordinador ve solo su equipo. + */ +export async function metricasUsoEndpoint(req: Request, res: Response): Promise { + const rol = req.usuarioToken?.rol + const sub = req.usuarioToken?.sub + + // Admin ve todos; coordinador podría filtrarse por equipo futuro, pero por + // ahora admin y coordinador ven lo mismo (todos los usuarios activos) + const usuarioId = rol === 'coordinador' && sub ? sub : undefined + const metricas = await obtenerMetricasUso(usuarioId) + res.json(metricas) +} diff --git a/api-gateway/src/middleware/auth.middleware.ts b/api-gateway/src/middleware/auth.middleware.ts index ae80a36..126f2f9 100644 --- a/api-gateway/src/middleware/auth.middleware.ts +++ b/api-gateway/src/middleware/auth.middleware.ts @@ -10,6 +10,7 @@ const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [ { metodo: 'GET', ruta: '/health' }, { metodo: 'POST', ruta: '/auth/login' }, { metodo: 'POST', ruta: '/webhooks/monday' }, + { metodo: 'GET', ruta: '/push/vapid-key' }, ] // Rutas públicas con parámetros (se comparan contra req.path relativo a /api). diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 799b496..ed4ef04 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -93,10 +93,24 @@ import { resetRolesEndpoint, obtenerAccionesEndpoint, } from '../controllers/config.controller.js' +import { crearNotaEndpoint, listarNotasEndpoint } from '../controllers/notas.controller.js' +import { listarAuditoriaEndpoint } from '../controllers/auditoria.controller.js' +import { buscarEndpoint } from '../controllers/buscar.controller.js' +import { + miTableroEndpoint, + metricasUsoEndpoint, + requireAdminOrCoordinador, +} from '../controllers/reporte-mi-tablero.controller.js' +import { + suscribirEndpoint, + desuscribirEndpoint, + vapidKeyEndpoint, +} from '../controllers/push.controller.js' export const apiRouter = Router() apiRouter.get('/health', health) +apiRouter.get('/buscar', buscarEndpoint) apiRouter.post('/auth/login', loginEndpoint) apiRouter.get('/auth/me', meEndpoint) @@ -163,6 +177,9 @@ apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint) // el cliente ligado al apartado (se crea la ficha mínima si aún no existe) apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado) apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array('archivos', 10), subirDocumentosApartado) +// Notas del apartado +apiRouter.post('/apartados/:id/notas', crearNotaEndpoint) +apiRouter.get('/apartados/:id/notas', listarNotasEndpoint) apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint) @@ -185,6 +202,10 @@ apiRouter.patch('/usuarios/me/monday', actualizarVinculoMondayEndpoint) apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint) apiRouter.get('/reportes/resumen', resumenReportes) +// Tablero personal del vendedor: métricas, conversión, ventas, origen, desarrollos +apiRouter.get('/reportes/mi-tablero', miTableroEndpoint) +// Métricas de uso (admin/coordinador): leads por usuario, tasa de descarte, actividades +apiRouter.get('/reportes/metricas-uso', requireAdminOrCoordinador, metricasUsoEndpoint) // Estado del modo local (banner del front): salud de Monday + cola de sync apiRouter.get('/sistema/estado', estadoSistema) @@ -202,6 +223,9 @@ apiRouter.post('/ai/documents', subirDocumento) apiRouter.get('/ai/documents', listarDocumentosIA) apiRouter.delete('/ai/documents/:id', eliminarDocumento) +// ── Auditoría (admin) ──────────────────────────────────────────────── +apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint) + // ── Configuración (admin) ────────────────────────────────────────────── apiRouter.get('/config/sistema', requireAdmin, obtenerConfigsEndpoint) apiRouter.patch('/config/sistema', requireAdmin, actualizarConfigsEndpoint) @@ -211,3 +235,8 @@ apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint) apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint) apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint) apiRouter.get('/config/acciones', obtenerAccionesEndpoint) + +// ── Push notifications ────────────────────────────────────────────── +apiRouter.get('/push/vapid-key', vapidKeyEndpoint) +apiRouter.post('/push/suscribir', suscribirEndpoint) +apiRouter.post('/push/desuscribir', desuscribirEndpoint) diff --git a/api-gateway/src/services/auditoria.service.test.ts b/api-gateway/src/services/auditoria.service.test.ts new file mode 100644 index 0000000..3eae19a --- /dev/null +++ b/api-gateway/src/services/auditoria.service.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado ---------- + +const prismaMock = vi.hoisted(() => ({ + auditoriaLog: { + create: vi.fn(), + findMany: vi.fn(), + }, +})) + +vi.mock('./prisma.js', () => ({ prisma: prismaMock })) + +import { registrar, buscar } from './auditoria.service.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('registrar', () => { + it('fire-and-forget: llama a prisma.create sin await', () => { + prismaMock.auditoriaLog.create.mockResolvedValue({}) + // registrar no devuelve promesa (fire-and-forget) + registrar('u1', 'CREAR_LEAD', 'lead', 'lead-1', 'detalle opcional') + expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({ + data: { usuarioId: 'u1', accion: 'CREAR_LEAD', entidad: 'lead', entidadId: 'lead-1', detalle: 'detalle opcional' }, + }) + }) + + it('maneja detalle undefined como null', () => { + prismaMock.auditoriaLog.create.mockResolvedValue({}) + registrar('u1', 'ACCION', 'ent', 'ent-1') + expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({ + data: { usuarioId: 'u1', accion: 'ACCION', entidad: 'ent', entidadId: 'ent-1', detalle: null }, + }) + }) + + it('no lanza error si la inserción falla (fire-and-forget)', async () => { + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}) + prismaMock.auditoriaLog.create.mockRejectedValue(new Error('DB error')) + // registrar no lanza + registrar('u1', 'ACCION', 'ent', 'ent-1') + // Esperar microtask para que el .catch se ejecute + await new Promise((r) => setTimeout(r, 10)) + expect(spyError).toHaveBeenCalled() + spyError.mockRestore() + }) +}) + +describe('buscar', () => { + it('sin filtros: retorna todos los logs ordenados por createdAt desc', async () => { + const logsFake = [{ id: 'log-1' }] + prismaMock.auditoriaLog.findMany.mockResolvedValue(logsFake) + + const resultado = await buscar({}) + expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { createdAt: 'desc' }, + take: 100, + }) + expect(resultado).toEqual(logsFake) + }) + + it('con filtros de entidad y usuarioId', async () => { + prismaMock.auditoriaLog.findMany.mockResolvedValue([]) + await buscar({ entidad: 'lead', usuarioId: 'u1' }) + expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ + where: { entidad: 'lead', usuarioId: 'u1' }, + orderBy: { createdAt: 'desc' }, + take: 100, + }) + }) + + it('con filtros de fecha', async () => { + const desde = new Date('2026-09-01') + const hasta = new Date('2026-09-07') + prismaMock.auditoriaLog.findMany.mockResolvedValue([]) + await buscar({ desde, hasta }) + expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ + where: { createdAt: { gte: desde, lte: hasta } }, + orderBy: { createdAt: 'desc' }, + take: 100, + }) + }) + + it('respeta el límite personalizado', async () => { + prismaMock.auditoriaLog.findMany.mockResolvedValue([]) + await buscar({ entidad: 'lead' }, 10) + expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({ + where: { entidad: 'lead' }, + orderBy: { createdAt: 'desc' }, + take: 10, + }) + }) +}) diff --git a/api-gateway/src/services/auditoria.service.ts b/api-gateway/src/services/auditoria.service.ts new file mode 100644 index 0000000..f22c623 --- /dev/null +++ b/api-gateway/src/services/auditoria.service.ts @@ -0,0 +1,49 @@ +import { prisma } from './prisma.js' + +export interface FiltrosAuditoria { + entidad?: string + entidadId?: string + usuarioId?: string + desde?: Date + hasta?: Date +} + +/** + * Registra un evento de auditoría. Fire-and-forget: no devuelve promesa + *awaitable para que el caller no se bloquee. + */ +export function registrar( + usuarioId: string, + accion: string, + entidad: string, + entidadId: string, + detalle?: string, +): void { + // Fire-and-forget: errors se loguean pero no se propagan + prisma.auditoriaLog + .create({ + data: { usuarioId, accion, entidad, entidadId, detalle: detalle ?? null }, + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error('[auditoria] Error registrando log:', err) + }) +} + +export async function buscar(filtros: FiltrosAuditoria, limite = 100) { + const where: Record = {} + if (filtros.entidad !== undefined) where['entidad'] = filtros.entidad + if (filtros.entidadId !== undefined) where['entidadId'] = filtros.entidadId + if (filtros.usuarioId !== undefined) where['usuarioId'] = filtros.usuarioId + if (filtros.desde !== undefined || filtros.hasta !== undefined) { + where['createdAt'] = { + ...(filtros.desde !== undefined ? { gte: filtros.desde } : {}), + ...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}), + } + } + return prisma.auditoriaLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limite, + }) +} diff --git a/api-gateway/src/services/automatizaciones.service.ts b/api-gateway/src/services/automatizaciones.service.ts index bc4b7b1..e80d047 100644 --- a/api-gateway/src/services/automatizaciones.service.ts +++ b/api-gateway/src/services/automatizaciones.service.ts @@ -10,6 +10,7 @@ import { } from './monday.service.js' import { prisma } from './prisma.js' import { saludMonday } from './salud-monday.service.js' +import { enviarPush, usuarioIdPorMondayUserId } from './push.service.js' /** * Automatizaciones sobre Monday: @@ -382,6 +383,11 @@ export class AutomatizacionesService { leadItemId: c.id, }) } + // Push notification al vendedor asignado + const usuarioId = await usuarioIdPorMondayUserId(vendedor.id) + if (usuarioId) { + void enviarPush(usuarioId, 'Nuevo lead asignado', `${c.nombre} — ${c.tablero}`, `/hoy/`) + } detalle.push({ itemId: c.id, lead: c.nombre, vendedorId: vendedor.id, vendedor: vendedor.nombre, tablero: c.tablero }) indice++ } @@ -700,6 +706,8 @@ export class AutomatizacionesService { } if (usuarioLocalId) { + // Push notification al vendedor asignado + void enviarPush(usuarioLocalId, 'Nuevo lead asignado', lead.nombreLead, '/hoy/') await prisma.asignacionLocal.create({ data: { leadCapturaId: lead.id, @@ -859,6 +867,41 @@ export class AutomatizacionesService { return { horasUmbral, candidatos, escalados } } + /** + * Escaneo de apartados por vencer: detecta apartados con fechaLimite en los + * próximos 3 días y envía push al vendedor. Dedup diario vía + * `apartado-push::`. + */ + async escaneoApartadosPorVencer(): Promise<{ revisados: number; enviados: number }> { + const ahora = new Date() + const limite = new Date(ahora) + limite.setDate(limite.getDate() + 3) + + const apartados = await prisma.leadCaptura.findMany({ + where: { + board: 'APARTADO', + etapa: { notIn: ['Cancelacion Apartado', 'Firma Cliente (Venta)'] }, + fechaLimite: { not: null, lte: limite, gte: ahora }, + }, + }) + + let enviados = 0 + for (const a of apartados) { + const clave = `apartado-push:${a.id}:${ahora.toISOString().slice(0, 10)}` + if ((await this.state.get(clave)) !== null) continue + void enviarPush( + a.usuarioId, + 'Apartado por vencer', + `${a.nombreLead} — vence ${a.fechaLimite?.toISOString().slice(0, 10) ?? 'pronto'}`, + '/hoy/', + ) + await this.state.set(clave, ahora.toISOString()) + enviados++ + } + + return { revisados: apartados.length, enviados } + } + /** Estado actual para el endpoint admin */ async estado(): Promise> { const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES) diff --git a/api-gateway/src/services/buscar.service.test.ts b/api-gateway/src/services/buscar.service.test.ts new file mode 100644 index 0000000..963221c --- /dev/null +++ b/api-gateway/src/services/buscar.service.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado ---------- + +const prismaMock = vi.hoisted(() => ({ + leadCaptura: { findMany: vi.fn() }, + cliente: { findMany: vi.fn() }, + cotizacion: { findMany: vi.fn() }, + unidad: { findMany: vi.fn() }, +})) + +vi.mock('./prisma.js', () => ({ prisma: prismaMock })) + +import { buscar } from './buscar.service.js' + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.leadCaptura.findMany.mockResolvedValue([]) + prismaMock.cliente.findMany.mockResolvedValue([]) + prismaMock.cotizacion.findMany.mockResolvedValue([]) + prismaMock.unidad.findMany.mockResolvedValue([]) +}) + +describe('buscar (GET /api/buscar)', () => { + it('término vacío retorna [] sin consultar la base', async () => { + const r = await buscar('') + expect(r).toEqual([]) + expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled() + }) + + it('término con solo espacios también retorna []', async () => { + const r = await buscar(' ') + expect(r).toEqual([]) + expect(prismaMock.leadCaptura.findMany).not.toHaveBeenCalled() + }) + + it('consulta los 4 modelos con el patrón contains', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { leadId: 'l1', nombreLead: 'María López', telefono: '4771112233', email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() }, + ]) + prismaMock.cliente.findMany.mockResolvedValue([ + { id: 'c1', nombreCompleto: 'María López', curp: '', rfc: '', email: 'maria@x.com', celular: '4771112233', proyecto: 'DIAGONAL', createdAt: new Date() }, + ]) + prismaMock.cotizacion.findMany.mockResolvedValue([]) + prismaMock.unidad.findMany.mockResolvedValue([]) + + const r = await buscar('María') + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledTimes(1) + expect(prismaMock.cliente.findMany).toHaveBeenCalledTimes(1) + expect(prismaMock.cotizacion.findMany).toHaveBeenCalledTimes(1) + expect(prismaMock.unidad.findMany).toHaveBeenCalledTimes(1) + expect(r.length).toBe(2) + expect(r[0]?.tipo).toBe('lead') + expect(r[1]?.tipo).toBe('contacto') + }) + + it('aplica take: limite por modelo', async () => { + await buscar('test', 5) + expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5 }), + ) + }) + + it('prioriza leads sobre contactos, contactos sobre oportunidades, oportunidades sobre unidades', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() }, + ]) + prismaMock.cliente.findMany.mockResolvedValue([ + { id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() }, + ]) + prismaMock.cotizacion.findMany.mockResolvedValue([ + { id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() }, + ]) + prismaMock.unidad.findMany.mockResolvedValue([ + { id: 'u1', codigo: 'X', desarrollo: 'SENDA', modelo: null, status: null, updatedAt: new Date() }, + ]) + + const r = await buscar('X') + expect(r.map((i) => i.tipo)).toEqual(['lead', 'contacto', 'oportunidad', 'unidad']) + }) + + it('mezcla los resultados y limita al total indicado', async () => { + const leads = Array.from({ length: 10 }, (_, i) => ({ + leadId: `l${i}`, nombreLead: `L${i}`, telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date(), + })) + const clientes = Array.from({ length: 10 }, (_, i) => ({ + id: `c${i}`, nombreCompleto: `C${i}`, curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date(), + })) + prismaMock.leadCaptura.findMany.mockResolvedValue(leads) + prismaMock.cliente.findMany.mockResolvedValue(clientes) + + const r = await buscar('x', 15) + expect(r).toHaveLength(15) + // Primeros 10 son leads (prioridad), siguientes 5 son contactos + expect(r.filter((i) => i.tipo === 'lead')).toHaveLength(10) + expect(r.filter((i) => i.tipo === 'contacto')).toHaveLength(5) + }) + + it('lead: detalle muestra teléfono · email, o origen como fallback', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { leadId: 'l1', nombreLead: 'Ana', telefono: '4771112233', email: 'ana@x.com', origen: null, board: 'LEADS', updatedAt: new Date() }, + ]) + const r = await buscar('Ana') + expect(r[0]?.detalle).toBe('4771112233 · ana@x.com') + + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { leadId: 'l2', nombreLead: 'Bob', telefono: null, email: null, origen: 'Facebook', board: 'LEADS', updatedAt: new Date() }, + ]) + const r2 = await buscar('Bob') + expect(r2[0]?.detalle).toBe('Facebook') + }) + + it('contacto: detalle muestra email · celular · proyecto', async () => { + prismaMock.cliente.findMany.mockResolvedValue([ + { id: 'c1', nombreCompleto: 'Luis', curp: '', rfc: '', email: 'luis@x.com', celular: '4772223333', proyecto: 'SENDA', createdAt: new Date() }, + ]) + const r = await buscar('Luis') + expect(r[0]?.detalle).toBe('luis@x.com · 4772223333 · SENDA') + }) + + it('oportunidad: detalle muestra unidadCodigo · desarrollo, o tipoPropuesta como fallback', async () => { + prismaMock.cotizacion.findMany.mockResolvedValue([ + { id: 'ct1', clienteNombre: 'Carlos', clienteEmail: null, unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', tipoPropuesta: null, createdAt: new Date() }, + ]) + const r = await buscar('Carlos') + expect(r[0]?.detalle).toBe('U-101 · DIAGONAL') + + prismaMock.cotizacion.findMany.mockResolvedValue([ + { id: 'ct2', clienteNombre: 'Diana', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: 'CONTADO', createdAt: new Date() }, + ]) + const r2 = await buscar('Diana') + expect(r2[0]?.detalle).toBe('CONTADO') + }) + + it('unidad: detalle muestra desarrollo · modelo · status', async () => { + prismaMock.unidad.findMany.mockResolvedValue([ + { id: 'u1', codigo: 'U-202', desarrollo: 'ESTANZA', modelo: 'SOHO', status: 'Disponible', updatedAt: new Date() }, + ]) + const r = await buscar('U-202') + expect(r[0]?.detalle).toBe('ESTANZA · SOHO · Disponible') + }) + + it('urls de navegación son correctas por tipo', async () => { + prismaMock.leadCaptura.findMany.mockResolvedValue([ + { leadId: 'l1', nombreLead: 'X', telefono: null, email: null, origen: null, board: 'LEADS', updatedAt: new Date() }, + ]) + prismaMock.cliente.findMany.mockResolvedValue([ + { id: 'c1', nombreCompleto: 'X', curp: '', rfc: '', email: null, celular: null, proyecto: '', createdAt: new Date() }, + ]) + prismaMock.cotizacion.findMany.mockResolvedValue([ + { id: 'ct1', clienteNombre: 'X', clienteEmail: null, unidadCodigo: null, desarrollo: null, tipoPropuesta: null, createdAt: new Date() }, + ]) + prismaMock.unidad.findMany.mockResolvedValue([ + { id: 'u1', codigo: 'X', desarrollo: null, modelo: null, status: null, updatedAt: new Date() }, + ]) + + const r = await buscar('X') + expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads') + expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes') + expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1') + expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X') + }) +}) diff --git a/api-gateway/src/services/buscar.service.ts b/api-gateway/src/services/buscar.service.ts new file mode 100644 index 0000000..435d84d --- /dev/null +++ b/api-gateway/src/services/buscar.service.ts @@ -0,0 +1,111 @@ +import { prisma } from './prisma.js' + +export interface BuscadoItem { + tipo: 'lead' | 'oportunidad' | 'contacto' | 'unidad' + id: string + nombre: string + detalle: string + url: string +} + +/** + * Búsqueda global: LeadCaptura (leads), Cliente (contactos), Cotizacion + * (oportunidades con cotización), y Unidad (inventario). + * Case-insensitive sobre los campos indicados; limita a `limite` resultados. + */ +export async function buscar(termino: string, limite = 20): Promise { + const q = termino.trim() + if (q === '') return [] + const pat = `%${q}%` + + const [leads, clientes, cotizaciones, unidades] = await Promise.all([ + prisma.leadCaptura.findMany({ + where: { + OR: [ + { nombreLead: { contains: q } }, + { telefono: { contains: q } }, + { email: { contains: q } }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: limite, + }), + prisma.cliente.findMany({ + where: { + OR: [ + { nombreCompleto: { contains: q } }, + { curp: { contains: q } }, + { rfc: { contains: q } }, + { email: { contains: q } }, + { celular: { contains: q } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: limite, + }), + prisma.cotizacion.findMany({ + where: { + OR: [ + { clienteNombre: { contains: q } }, + { clienteEmail: { contains: q } }, + { unidadCodigo: { contains: q } }, + ], + }, + orderBy: { createdAt: 'desc' }, + take: limite, + }), + prisma.unidad.findMany({ + where: { + OR: [ + { codigo: { contains: q } }, + { desarrollo: { contains: q } }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: limite, + }), + ]) + + const items: BuscadoItem[] = [ + ...leads.map((l) => ({ + tipo: 'lead' as const, + id: l.leadId, + nombre: l.nombreLead, + detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board, + url: `/app/#leads`, + })), + ...clientes.map((c) => ({ + tipo: 'contacto' as const, + id: c.id, + nombre: c.nombreCompleto, + detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '', + url: `/app/#clientes`, + })), + ...cotizaciones.map((ct) => ({ + tipo: 'oportunidad' as const, + id: ct.id, + nombre: ct.clienteNombre, + detalle: [ct.unidadCodigo, ct.desarrollo].filter(Boolean).join(' · ') || ct.tipoPropuesta, + url: `/quote/?cotizacion=${ct.id}`, + })), + ...unidades.map((u) => ({ + tipo: 'unidad' as const, + id: u.id, + nombre: u.codigo, + detalle: [u.desarrollo, u.modelo, u.status].filter(Boolean).join(' · '), + url: `/quote/?unidad=${u.codigo}`, + })), + ] + + // Mezclar y limitar: prioridad por tipo (leads primero, luego contactos, + // oportunidades, unidades) y recientes primero (ya ordenados por fecha desc) + const porTipo: BuscadoItem['tipo'][] = ['lead', 'contacto', 'oportunidad', 'unidad'] + items.sort((a, b) => { + const ia = porTipo.indexOf(a.tipo) + const ib = porTipo.indexOf(b.tipo) + if (ia !== ib) return ia - ib + return 0 + }) + + return items.slice(0, limite) +} diff --git a/api-gateway/src/services/nota-apartado.service.test.ts b/api-gateway/src/services/nota-apartado.service.test.ts new file mode 100644 index 0000000..766741d --- /dev/null +++ b/api-gateway/src/services/nota-apartado.service.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado ---------- + +const prismaMock = vi.hoisted(() => ({ + notaApartado: { + create: vi.fn(), + findMany: vi.fn(), + }, +})) + +vi.mock('./prisma.js', () => ({ prisma: prismaMock })) + +import { crearNota, listarNotas } from './nota-apartado.service.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('crearNota', () => { + it('crea una nota con los datos correctos', async () => { + const notaFake = { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Hola', createdAt: new Date() } + prismaMock.notaApartado.create.mockResolvedValue(notaFake) + + const resultado = await crearNota('apt-1', 'u1', 'Hola') + expect(prismaMock.notaApartado.create).toHaveBeenCalledWith({ + data: { apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'Hola' }, + }) + expect(resultado).toEqual(notaFake) + }) +}) + +describe('listarNotas', () => { + it('retorna notas ordenadas por createdAt desc', async () => { + const notasFake = [ + { id: 'nota-2', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'B', createdAt: new Date('2026-09-02') }, + { id: 'nota-1', apartadoId: 'apt-1', usuarioId: 'u1', contenido: 'A', createdAt: new Date('2026-09-01') }, + ] + prismaMock.notaApartado.findMany.mockResolvedValue(notasFake) + + const resultado = await listarNotas('apt-1') + expect(prismaMock.notaApartado.findMany).toHaveBeenCalledWith({ + where: { apartadoId: 'apt-1' }, + orderBy: { createdAt: 'desc' }, + }) + expect(resultado).toEqual(notasFake) + }) + + it('retorna array vacío si no hay notas', async () => { + prismaMock.notaApartado.findMany.mockResolvedValue([]) + const resultado = await listarNotas('apt-inexistente') + expect(resultado).toEqual([]) + }) +}) diff --git a/api-gateway/src/services/nota-apartado.service.ts b/api-gateway/src/services/nota-apartado.service.ts new file mode 100644 index 0000000..ef24b99 --- /dev/null +++ b/api-gateway/src/services/nota-apartado.service.ts @@ -0,0 +1,26 @@ +import { prisma } from './prisma.js' + +export interface NotaApartado { + id: string + apartadoId: string + usuarioId: string + contenido: string + createdAt: Date +} + +export async function crearNota( + apartadoId: string, + usuarioId: string, + contenido: string, +): Promise { + return prisma.notaApartado.create({ + data: { apartadoId, usuarioId, contenido }, + }) +} + +export async function listarNotas(apartadoId: string): Promise { + return prisma.notaApartado.findMany({ + where: { apartadoId }, + orderBy: { createdAt: 'desc' }, + }) +} diff --git a/api-gateway/src/services/push.service.test.ts b/api-gateway/src/services/push.service.test.ts new file mode 100644 index 0000000..26fab29 --- /dev/null +++ b/api-gateway/src/services/push.service.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// ---------- Prisma mockeado ---------- +const prismaMock = vi.hoisted(() => ({ + pushSubscription: { + upsert: vi.fn(), + deleteMany: vi.fn(), + findUnique: vi.fn(), + }, + usuario: { + findFirst: vi.fn(), + }, +})) + +vi.mock('./prisma.js', () => ({ prisma: prismaMock })) + +// ---------- web-push mockeado ---------- +const webPushMock = vi.hoisted(() => ({ + setVapidDetails: vi.fn(), + sendNotification: vi.fn(), +})) + +vi.mock('web-push', () => ({ default: webPushMock })) + +// ---------- config mockeado ---------- +vi.mock('../config/index.js', () => ({ + config: { + push: { + vapidPublicKey: 'test-public-key', + vapidPrivateKey: 'test-private-key', + vapidEmail: 'mailto:test@frasa.com', + }, + }, +})) + +import { + registrarSuscripcion, + eliminarSuscripcion, + enviarPush, + usuarioIdPorMondayUserId, + obtenerVapidPublicKey, +} from './push.service.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('push.service', () => { + describe('registrarSuscripcion', () => { + it('hace upsert con los datos de la suscripción', async () => { + prismaMock.pushSubscription.upsert.mockResolvedValue({}) + await registrarSuscripcion('user-1', { + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + p256dh: 'key123', + auth: 'auth456', + }) + expect(prismaMock.pushSubscription.upsert).toHaveBeenCalledWith({ + where: { usuarioId: 'user-1' }, + update: { + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + p256dh: 'key123', + auth: 'auth456', + }, + create: { + usuarioId: 'user-1', + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + p256dh: 'key123', + auth: 'auth456', + }, + }) + }) + }) + + describe('eliminarSuscripcion', () => { + it('elimina todas las suscripciones del usuario', async () => { + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }) + await eliminarSuscripcion('user-1') + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ where: { usuarioId: 'user-1' } }) + }) + }) + + describe('enviarPush', () => { + it('envía push si el usuario tiene suscripción', async () => { + prismaMock.pushSubscription.findUnique.mockResolvedValue({ + endpoint: 'https://fcm.googleapis.com/fcm/send/abc', + p256dh: 'key123', + auth: 'auth456', + }) + webPushMock.sendNotification.mockResolvedValue({ statusCode: 201 }) + + const result = await enviarPush('user-1', 'Test', 'Cuerpo del mensaje') + expect(result).toBe(true) + expect(webPushMock.sendNotification).toHaveBeenCalledWith( + { endpoint: 'https://fcm.googleapis.com/fcm/send/abc', keys: { p256dh: 'key123', auth: 'auth456' } }, + expect.stringContaining('"titulo":"Test"'), + ) + }) + + it('devuelve false si el usuario no tiene suscripción', async () => { + prismaMock.pushSubscription.findUnique.mockResolvedValue(null) + const result = await enviarPush('user-1', 'Test', 'Cuerpo') + expect(result).toBe(false) + expect(webPushMock.sendNotification).not.toHaveBeenCalled() + }) + + it('elimina suscripción inválida (404) y devuelve false', async () => { + prismaMock.pushSubscription.findUnique.mockResolvedValue({ + endpoint: 'https://expired', + p256dh: 'key', + auth: 'auth', + }) + webPushMock.sendNotification.mockRejectedValue({ statusCode: 404 }) + prismaMock.pushSubscription.deleteMany.mockResolvedValue({ count: 1 }) + + const result = await enviarPush('user-1', 'Test', 'Cuerpo') + expect(result).toBe(false) + expect(prismaMock.pushSubscription.deleteMany).toHaveBeenCalledWith({ where: { usuarioId: 'user-1' } }) + }) + }) + + describe('usuarioIdPorMondayUserId', () => { + it('devuelve el usuarioId si encuentra el usuario', async () => { + prismaMock.usuario.findFirst.mockResolvedValue({ id: 'local-user-1' }) + const result = await usuarioIdPorMondayUserId(12345) + expect(result).toBe('local-user-1') + }) + + it('devuelve null si no encuentra el usuario', async () => { + prismaMock.usuario.findFirst.mockResolvedValue(null) + const result = await usuarioIdPorMondayUserId(99999) + expect(result).toBeNull() + }) + }) + + describe('obtenerVapidPublicKey', () => { + it('devuelve la VAPID public key del config', () => { + expect(obtenerVapidPublicKey()).toBe('test-public-key') + }) + }) +}) diff --git a/api-gateway/src/services/push.service.ts b/api-gateway/src/services/push.service.ts new file mode 100644 index 0000000..5866cab --- /dev/null +++ b/api-gateway/src/services/push.service.ts @@ -0,0 +1,87 @@ +import webPush from 'web-push' +import { config } from '../config/index.js' +import { prisma } from './prisma.js' + +// Configurar VAPID solo si las claves están presentes +if (config.push.vapidPublicKey && config.push.vapidPrivateKey) { + webPush.setVapidDetails( + config.push.vapidEmail, + config.push.vapidPublicKey, + config.push.vapidPrivateKey, + ) +} + +export interface PushSubscriptionInput { + endpoint: string + p256dh: string + auth: string +} + +/** Registra o actualiza la suscripción push de un usuario */ +export async function registrarSuscripcion(usuarioId: string, subscription: PushSubscriptionInput): Promise { + await prisma.pushSubscription.upsert({ + where: { usuarioId }, + update: { + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + create: { + usuarioId, + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }) +} + +/** Elimina la suscripción push de un usuario */ +export async function eliminarSuscripcion(usuarioId: string): Promise { + await prisma.pushSubscription.deleteMany({ where: { usuarioId } }) +} + +/** Devuelve la VAPID public key para el frontend */ +export function obtenerVapidPublicKey(): string { + return config.push.vapidPublicKey +} + +/** Busca el usuarioId local a partir de un mondayUserId */ +export async function usuarioIdPorMondayUserId(mondayUserId: number): Promise { + const usuario = await prisma.usuario.findFirst({ + where: { mondayUserId }, + select: { id: true }, + }) + return usuario?.id ?? null +} + +/** Envía una notificación push a un usuario. Silencioso si no tiene suscripción o si web-push no está configurado. */ +export async function enviarPush( + usuarioId: string, + titulo: string, + cuerpo: string, + url?: string, +): Promise { + if (!config.push.vapidPublicKey || !config.push.vapidPrivateKey) return false + + const sub = await prisma.pushSubscription.findUnique({ where: { usuarioId } }) + if (!sub) return false + + const payload = JSON.stringify({ titulo, cuerpo, url: url ?? '/' }) + + try { + await webPush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + payload, + ) + return true + } catch (err: unknown) { + // Si la suscripción ya no es válida (404 o 410), eliminarla + const status = (err as { statusCode?: number }).statusCode + if (status === 404 || status === 410) { + await prisma.pushSubscription.deleteMany({ where: { usuarioId } }) + } + // eslint-disable-next-line no-console + console.error(`[push] No se pudo enviar a ${usuarioId}:`, err) + return false + } +} diff --git a/api-gateway/src/services/recordatorios-cobranza.service.ts b/api-gateway/src/services/recordatorios-cobranza.service.ts index 706976c..c8df849 100644 --- a/api-gateway/src/services/recordatorios-cobranza.service.ts +++ b/api-gateway/src/services/recordatorios-cobranza.service.ts @@ -3,6 +3,7 @@ import { parseFechaLocal, round2, statusPago, type Pago, type PlanPago } from '. import { LocalCanal } from './local-canal.js' import { mondayService, type MondayService } from './monday.service.js' import { prisma } from './prisma.js' +import { enviarPush } from './push.service.js' /** * Motor de recordatorios de pago de la cobranza. @@ -272,6 +273,10 @@ export class RecordatoriosCobranzaService { if (canal) await canal.enviar(destinatario, c.mensaje, hoyISO(ahora)) await this.store.registrarLog(enviado) resultado.enviados.push(enviado) + // Push notification al vendedor + if (c.vendedorId) { + void enviarPush(c.vendedorId, 'Recordatorio de pago', c.mensaje, '/hoy/') + } } catch (err) { // Un fallo del canal NO queda en el log: la siguiente pasada reintenta resultado.fallidos.push({ pagoId: c.pagoId, error: err instanceof Error ? err.message : String(err) }) diff --git a/api-gateway/src/services/reporte-mi-tablero.service.test.ts b/api-gateway/src/services/reporte-mi-tablero.service.test.ts new file mode 100644 index 0000000..023d754 --- /dev/null +++ b/api-gateway/src/services/reporte-mi-tablero.service.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { construirMiTablero } from './reporte-mi-tablero.service.js' + +// ---------- Fixtures ---------- + +function captura( + overrides: Partial<{ + leadId: string + board: string + etapa: string + origen: string | null + desarrollo: string | null + createdAt: Date + }> = {}, +) { + return { + leadId: overrides.leadId ?? `cap-${Math.random().toString(36).slice(2, 8)}`, + board: overrides.board ?? 'LEADS', + etapa: overrides.etapa ?? 'NUEVO', + origen: overrides.origen ?? 'Facebook', + desarrollo: overrides.desarrollo ?? 'DIAGONAL', + createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'), + } +} + +function cotizacion( + overrides: Partial<{ precioFinal: number; vendedorId: string | null; createdAt: Date }> = {}, +) { + return { + precioFinal: overrides.precioFinal ?? 850000, + vendedorId: overrides.vendedorId ?? 'u-1', + createdAt: overrides.createdAt ?? new Date('2026-09-01T12:00:00'), + } +} + +function actividad( + overrides: Partial<{ usuarioId: string; estado: string; fechaVencimiento: Date | null }> = {}, +) { + return { + usuarioId: overrides.usuarioId ?? 'u-1', + estado: overrides.estado ?? 'ABIERTA', + fechaVencimiento: overrides.fechaVencimiento ?? null, + } +} + +const AHORA = new Date('2026-09-07T12:00:00') + +// ---------- construirMiTablero (puro) ---------- + +describe('construirMiTablero', () => { + it('genera 30 entradas en leadsPorDia con ceros para días sin leads', () => { + const tablero = construirMiTablero([], [], [], AHORA) + expect(tablero.leadsPorDia.length).toBe(30) + // Todos los días deben tener cantidad 0 + expect(tablero.leadsPorDia.every((d) => d.cantidad === 0)).toBe(true) + }) + + it('cuenta leads por día correctamente', () => { + const hace2Dias = new Date(AHORA.getTime() - 2 * 24 * 60 * 60 * 1000) + const capturas = [ + captura({ createdAt: hace2Dias }), + captura({ createdAt: hace2Dias }), + captura({ createdAt: hace2Dias }), + ] + const tablero = construirMiTablero(capturas, [], [], AHORA) + const entrada = tablero.leadsPorDia.find((d) => d.cantidad > 0) + expect(entrada).toBeDefined() + expect(entrada!.cantidad).toBe(3) + }) + + it('cuenta conversión correctamente', () => { + const capturas = [ + captura({ board: 'LEADS' }), + captura({ board: 'LEADS' }), + captura({ board: 'OPORTUNIDADES' }), + captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }), + captura({ board: 'APARTADO', etapa: 'CANCELADO' }), + ] + const tablero = construirMiTablero(capturas, [], [], AHORA) + expect(tablero.conversion.leads).toBe(2) + expect(tablero.conversion.oportunidades).toBe(1) + expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta + expect(tablero.conversion.vendidos).toBe(1) + }) + + it('calcula ventas del mes correctamente', () => { + const cots = [ + cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }), + cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }), + cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior + ] + const tablero = construirMiTablero([], cots, [], AHORA) + expect(tablero.ventasMes.unidades).toBe(2) + expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000 + }) + + it('agrupa por origen con conteo de convertidos', () => { + const capturas = [ + captura({ board: 'LEADS', origen: 'Facebook' }), + captura({ board: 'LEADS', origen: 'Facebook' }), + captura({ board: 'OPORTUNIDADES', origen: 'Facebook' }), + captura({ board: 'LEADS', origen: 'Referido' }), + ] + const tablero = construirMiTablero(capturas, [], [], AHORA) + const fb = tablero.porOrigen.find((o) => o.origen === 'Facebook') + expect(fb).toBeDefined() + expect(fb!.total).toBe(3) + expect(fb!.convertidos).toBe(1) + const ref = tablero.porOrigen.find((o) => o.origen === 'Referido') + expect(ref).toBeDefined() + expect(ref!.total).toBe(1) + expect(ref!.convertidos).toBe(0) + }) + + it('agrupa por desarrollo solo con vendidos', () => { + const capturas = [ + captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }), + captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }), + captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }), + captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido + ] + const tablero = construirMiTablero(capturas, [], [], AHORA) + expect(tablero.porDesarrollo).toEqual([ + { desarrollo: 'DIAGONAL', ventas: 2 }, + { desarrollo: 'ATELIER', ventas: 1 }, + ]) + }) + + it('cuenta actividades hoy y pendientes', () => { + const actividades = [ + actividad({ estado: 'ABIERTA', fechaVencimiento: new Date('2026-09-07T10:00:00') }), + actividad({ estado: 'ABIERTA', fechaVencimiento: new Date('2026-09-08T10:00:00') }), + actividad({ estado: 'ABIERTA' }), + actividad({ estado: 'CERRADA' }), + ] + const tablero = construirMiTablero([], [], actividades, AHORA) + expect(tablero.actividadesHoy).toBe(2) // 1 vencida + 1 sin fecha (hoy) + expect(tablero.actividadesPendientes).toBe(3) // 3 ABIERTA + }) + + it('normaliza orígenes vacíos a (sin origen)', () => { + const capturas = [captura({ board: 'LEADS', origen: '' })] + const tablero = construirMiTablero(capturas, [], [], AHORA) + expect(tablero.porOrigen.length).toBe(1) + expect(tablero.porOrigen[0].origen).toBe('(sin origen)') + }) +}) diff --git a/api-gateway/src/services/reporte-mi-tablero.service.ts b/api-gateway/src/services/reporte-mi-tablero.service.ts new file mode 100644 index 0000000..ad9318e --- /dev/null +++ b/api-gateway/src/services/reporte-mi-tablero.service.ts @@ -0,0 +1,311 @@ +import { prisma } from './prisma.js' +import { AppError } from '../utils/errors.js' + +// ---------- Interfaces ---------- + +export interface MiTablero { + generadoEn: string + leadsPorDia: { fecha: string; cantidad: number }[] + conversion: { + leads: number + oportunidades: number + apartados: number + vendidos: number + } + ventasMes: { + monto: number + unidades: number + } + porOrigen: { + origen: string + total: number + convertidos: number + }[] + porDesarrollo: { + desarrollo: string + ventas: number + }[] + actividadesHoy: number + actividadesPendientes: number +} + +export interface MetricasUsoUsuario { + usuarioId: string + nombre: string + rol: string + leadsHoy: number + leadsSemana: number + leadsMes: number + tasaDescarte: number + actividadesCompletadas: number + actividadesPendientes: number +} + +export interface MetricasUso { + generadoEn: string + usuarios: MetricasUsoUsuario[] +} + +// ---------- Helpers ---------- + +/** Inicio del día local de `ahora` (medianoche) */ +function inicioDelDia(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()) +} + +/** Fecha local 'YYYY-MM-DD' */ +function formatoFecha(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dd = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${dd}` +} + +// ---------- Servicio: Mi Tablero ---------- + +/** + * Construye el tablero personal del vendedor: leads por día (últimos 30 días), + * tasa de conversión, ventas del mes, rendimiento por origen, ranking de + * desarrollos y conteo de actividades. + * + * Función pura: recibe los datos crudos desde Prisma; testeable sin BD. + */ +export function construirMiTablero( + capturas: { + leadId: string + board: string + etapa: string + origen: string | null + desarrollo: string | null + createdAt: Date + }[], + cotizaciones: { + precioFinal: number + vendedorId: string | null + createdAt: Date + }[], + actividades: { + usuarioId: string + estado: string + fechaVencimiento: Date | null + }[], + ahora: Date, +): MiTablero { + const hoy = inicioDelDia(ahora) + const hace30Dias = new Date(hoy.getTime() - 30 * 24 * 60 * 60 * 1000) + + // -- Leads por día (últimos 30 días) -- + const leadsPorDiaMap = new Map() + // Inicializar todos los días para que la gráfica no tenga huecos + for (let i = 0; i < 30; i++) { + const d = new Date(hoy.getTime() - (29 - i) * 24 * 60 * 60 * 1000) + leadsPorDiaMap.set(formatoFecha(d), 0) + } + for (const c of capturas) { + const fecha = c.createdAt + if (fecha >= hace30Dias) { + const clave = formatoFecha(fecha) + if (leadsPorDiaMap.has(clave)) { + leadsPorDiaMap.set(clave, (leadsPorDiaMap.get(clave) ?? 0) + 1) + } + } + } + const leadsPorDia = [...leadsPorDiaMap.entries()] + .map(([fecha, cantidad]) => ({ fecha, cantidad })) + .sort((a, b) => a.fecha.localeCompare(b.fecha)) + + // -- Conversión (funnel) -- + const leads = capturas.filter((c) => c.board === 'LEADS').length + const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length + const apartados = capturas.filter( + (c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO', + ).length + const vendidos = capturas.filter( + (c) => + c.board === 'APARTADO' && + ['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()), + ).length + + // -- Ventas del mes -- + const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) + const cotizacionesMes = cotizaciones.filter( + (ct) => ct.createdAt >= inicioMes, + ) + const ventasMes = { + monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100, + unidades: cotizacionesMes.length, + } + + // -- Por origen -- + const origenMap = new Map() + for (const c of capturas) { + const origen = c.origen?.trim() || '(sin origen)' + const entrada = origenMap.get(origen) ?? { total: 0, convertidos: 0 } + entrada.total++ + if (c.board === 'OPORTUNIDADES' || c.board === 'APARTADO') { + entrada.convertidos++ + } + origenMap.set(origen, entrada) + } + const porOrigen = [...origenMap.entries()] + .map(([origen, v]) => ({ origen, ...v })) + .sort((a, b) => b.total - a.total) + + // -- Por desarrollo (solo apartados vendidos) -- + const desarrolloMap = new Map() + for (const c of capturas) { + if ( + c.board === 'APARTADO' && + ['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) && + c.desarrollo + ) { + const dev = c.desarrollo.trim() + if (dev !== '') { + desarrolloMap.set(dev, (desarrolloMap.get(dev) ?? 0) + 1) + } + } + } + const porDesarrollo = [...desarrolloMap.entries()] + .map(([desarrollo, ventas]) => ({ desarrollo, ventas })) + .sort((a, b) => b.ventas - a.ventas) + + // -- Actividades -- + const manana = new Date(hoy.getTime() + 24 * 60 * 60 * 1000) + const actividadesHoy = actividades.filter( + (a) => + a.estado.toUpperCase() === 'ABIERTA' && + (!a.fechaVencimiento || a.fechaVencimiento < manana), + ).length + const actividadesPendientes = actividades.filter( + (a) => a.estado.toUpperCase() === 'ABIERTA', + ).length + + return { + generadoEn: ahora.toISOString(), + leadsPorDia, + conversion: { leads, oportunidades, apartados, vendidos }, + ventasMes, + porOrigen, + porDesarrollo, + actividadesHoy, + actividadesPendientes, + } +} + +// ---------- Servicio con Prisma ---------- + +/** + * Obtiene el tablero personal de un vendedor desde la base de datos. + */ +export async function obtenerMiTablero(usuarioId: string): Promise { + const ahora = new Date() + + const capturas = await prisma.leadCaptura.findMany({ + where: { usuarioId }, + select: { + board: true, + etapa: true, + origen: true, + desarrollo: true, + createdAt: true, + }, + }) + + const cotizaciones = await prisma.cotizacion.findMany({ + where: { vendedorId: usuarioId }, + select: { + precioFinal: true, + vendedorId: true, + createdAt: true, + }, + }) + + const actividades = await prisma.actividadLocal.findMany({ + where: { usuarioId }, + select: { + usuarioId: true, + estado: true, + fechaVencimiento: true, + }, + }) + + return construirMiTablero(capturas, cotizaciones, actividades, ahora) +} + +// ---------- Servicio: Métricas de uso (admin/coordinador) ---------- + +/** + * Métricas de uso por usuario: leads capturados (hoy, semana, mes), tasa de + * descarte, actividades completadas vs pendientes. Visible solo para admin y + * coordinador. + */ +export async function obtenerMetricasUso(usuarioId?: string): Promise { + const ahora = new Date() + const hoy = inicioDelDia(ahora) + const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000) + const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) + + // Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos) + const whereUsuario = usuarioId ? { usuarioId } : undefined + + const [capturas, actividades, usuarios] = await Promise.all([ + prisma.leadCaptura.findMany({ + where: whereUsuario, + select: { + usuarioId: true, + etapa: true, + createdAt: true, + }, + }), + prisma.actividadLocal.findMany({ + where: whereUsuario, + select: { + usuarioId: true, + estado: true, + }, + }), + prisma.usuario.findMany({ + where: whereUsuario ? { id: usuarioId } : { activo: true }, + select: { + id: true, + nombre: true, + rol: true, + }, + }), + ]) + + const metricasPorUsuario: MetricasUsoUsuario[] = usuarios.map((u) => { + const misCapturas = capturas.filter((c) => c.usuarioId === u.id) + const leadsHoy = misCapturas.filter((c) => c.createdAt >= hoy).length + const leadsSemana = misCapturas.filter((c) => c.createdAt >= hace7dias).length + const leadsMes = misCapturas.filter((c) => c.createdAt >= inicioMes).length + const descartados = misCapturas.filter((c) => + c.etapa.toUpperCase() === 'DESCARTADO', + ).length + const tasaDescarte = leadsMes > 0 ? Math.round((descartados / leadsMes) * 100) / 100 : 0 + const misActividades = actividades.filter((a) => a.usuarioId === u.id) + const actividadesCompletadas = misActividades.filter( + (a) => a.estado.toUpperCase() === 'CERRADA', + ).length + const actividadesPendientes = misActividades.filter( + (a) => a.estado.toUpperCase() === 'ABIERTA', + ).length + + return { + usuarioId: u.id, + nombre: u.nombre, + rol: u.rol, + leadsHoy, + leadsSemana, + leadsMes, + tasaDescarte, + actividadesCompletadas, + actividadesPendientes, + } + }) + + return { + generadoEn: ahora.toISOString(), + usuarios: metricasPorUsuario, + } +} diff --git a/apps/agente-ia/package-lock.json b/apps/agente-ia/package-lock.json index 5e76dce..e36139b 100644 --- a/apps/agente-ia/package-lock.json +++ b/apps/agente-ia/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@fontsource/inter": "^5.3.0", + "lucide-react": "^1.41.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -1811,7 +1813,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -1995,6 +1996,15 @@ "node": ">=6.9.0" } }, + "node_modules/goober": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz", + "integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2335,6 +2345,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.41.0.tgz", + "integrity": "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2468,6 +2487,23 @@ "react": "^19.2.8" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/apps/agente-ia/package.json b/apps/agente-ia/package.json index 0fb6fec..c90bf7f 100644 --- a/apps/agente-ia/package.json +++ b/apps/agente-ia/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "@fontsource/inter": "^5.3.0", + "lucide-react": "^1.41.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/apps/agente-ia/src/components/AuthGate.tsx b/apps/agente-ia/src/components/AuthGate.tsx index 877e788..01ff0fa 100644 --- a/apps/agente-ia/src/components/AuthGate.tsx +++ b/apps/agente-ia/src/components/AuthGate.tsx @@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) { } return ( -
-
+
+
- + {sesion.usuario.nombre} · {sesion.usuario.rol}
diff --git a/apps/agente-ia/src/components/Burbuja.tsx b/apps/agente-ia/src/components/Burbuja.tsx index 2fe367c..c097cf5 100644 --- a/apps/agente-ia/src/components/Burbuja.tsx +++ b/apps/agente-ia/src/components/Burbuja.tsx @@ -9,7 +9,7 @@ function Burbuja({ mensaje }: { mensaje: Mensaje }) { className={`inline-block whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-relaxed ${ esUsuario ? 'rounded-br-md bg-accent text-white shadow-sm' - : 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] shadow-sm' + : 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] dark:text-[#E2E8F0] shadow-sm' }`} > {mensaje.contenido} diff --git a/apps/agente-ia/src/components/CambiarPasswordModal.tsx b/apps/agente-ia/src/components/CambiarPasswordModal.tsx index 0358db2..6e8214a 100644 --- a/apps/agente-ia/src/components/CambiarPasswordModal.tsx +++ b/apps/agente-ia/src/components/CambiarPasswordModal.tsx @@ -3,8 +3,8 @@ import { validarConfirmacion, validarPasswordNueva } from '../lib/password' import { cambiarPassword } from '../services/auth' const CLASE_INPUT = - 'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20' -const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563]' + 'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20' +const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]' /** * Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente @@ -46,7 +46,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi aria-label="Cambiar contraseña" >
-

+

Cambiar contraseña

{exito ? ( @@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi diff --git a/apps/agente-ia/src/components/DarkModeToggle.tsx b/apps/agente-ia/src/components/DarkModeToggle.tsx new file mode 100644 index 0000000..06a138d --- /dev/null +++ b/apps/agente-ia/src/components/DarkModeToggle.tsx @@ -0,0 +1,32 @@ +import { Sun, Moon } from "lucide-react" +import { useEffect, useState } from "react" + +function getStoredTheme(): boolean { + if (typeof window === "undefined") return false + try { + const stored = localStorage.getItem("frasa_theme") + if (stored) return stored === "dark" + return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false + } catch { + return false + } +} + +export default function DarkModeToggle() { + const [dark, setDark] = useState(getStoredTheme) + + useEffect(() => { + document.documentElement.classList.toggle("dark", dark) + try { + localStorage.setItem("frasa_theme", dark ? "dark" : "light") + } catch { + // localStorage not available (test env) + } + }, [dark]) + + return ( + + ) +} diff --git a/apps/agente-ia/src/components/LoginScreen.tsx b/apps/agente-ia/src/components/LoginScreen.tsx index 5f8d7fb..d2a107c 100644 --- a/apps/agente-ia/src/components/LoginScreen.tsx +++ b/apps/agente-ia/src/components/LoginScreen.tsx @@ -23,12 +23,12 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes return (
-
+

FRASA.

-

+

Acceso al ecosistema

@@ -36,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
@@ -48,13 +48,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes autoComplete="username" autoFocus required - className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" + className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" />
@@ -65,7 +65,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" required - className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" + className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" />
{error && ( diff --git a/apps/agente-ia/src/components/Skeleton.tsx b/apps/agente-ia/src/components/Skeleton.tsx new file mode 100644 index 0000000..e937721 --- /dev/null +++ b/apps/agente-ia/src/components/Skeleton.tsx @@ -0,0 +1,21 @@ +export default function Skeleton({ className = "" }: { className?: string }) { + return
+} + +export function SkeletonCard() { + return ( +
+ + + +
+ ) +} + +export function SkeletonList({ count = 5 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => )} +
+ ) +} diff --git a/apps/agente-ia/src/index.css b/apps/agente-ia/src/index.css index f68f994..038e755 100644 --- a/apps/agente-ia/src/index.css +++ b/apps/agente-ia/src/index.css @@ -1,3 +1,5 @@ +@custom-variant dark (&:where(.dark, .dark *)); + @import "tailwindcss"; @theme { diff --git a/apps/agente-ia/src/main.tsx b/apps/agente-ia/src/main.tsx index c8f6efc..9c8b2b4 100644 --- a/apps/agente-ia/src/main.tsx +++ b/apps/agente-ia/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { Toaster } from 'react-hot-toast' import '@fontsource/inter/300.css' import '@fontsource/inter/400.css' import '@fontsource/inter/500.css' @@ -13,5 +14,6 @@ createRoot(document.getElementById('root')!).render( + , ) diff --git a/apps/agente-ia/src/pages/ChatPage.tsx b/apps/agente-ia/src/pages/ChatPage.tsx index c5937b5..d66dae7 100644 --- a/apps/agente-ia/src/pages/ChatPage.tsx +++ b/apps/agente-ia/src/pages/ChatPage.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react' import Burbuja from '../components/Burbuja' +import DarkModeToggle from '../components/DarkModeToggle' +import toast from "react-hot-toast" import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api' import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion' import type { Mensaje } from '../types' @@ -59,7 +61,7 @@ export default function ChatPage() { if (esErrorDeRed(err)) { setSinConexion(true) } else { - setError(err instanceof Error ? err.message : 'Error al enviar el mensaje') + toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje') } // el mensaje del usuario se queda visible; puede reintentar } finally { @@ -76,29 +78,32 @@ export default function ChatPage() { } return ( -
-
+
+
← Menú FRASA · - ASISTENTE IA + ASISTENTE IA
- +
+ + +
@@ -117,11 +122,11 @@ export default function ChatPage() { {mensajes.length === 0 && !escribiendo ? (
-
-

+

+

Asistente interno

-

+

Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta o las políticas de Frasa.

@@ -131,7 +136,7 @@ export default function ChatPage() { key={pregunta} type="button" onClick={() => setEntrada(pregunta)} - className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-left text-xs font-medium text-[#374151] transition-colors hover:border-accent hover:text-accent" + className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-left text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-colors hover:border-accent hover:text-accent" > {pregunta} @@ -149,11 +154,11 @@ export default function ChatPage() { )} -