feat(mejoras): 14 mejoras al CRM Frasa
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.
This commit is contained in:
95
api-gateway/package-lock.json
generated
95
api-gateway/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
syncPendiente: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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") ----------
|
||||
|
||||
93
api-gateway/src/controllers/auditoria.controller.test.ts
Normal file
93
api-gateway/src/controllers/auditoria.controller.test.ts
Normal file
@@ -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<string, string>, 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([])
|
||||
})
|
||||
})
|
||||
31
api-gateway/src/controllers/auditoria.controller.ts
Normal file
31
api-gateway/src/controllers/auditoria.controller.ts
Normal file
@@ -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<void> {
|
||||
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)
|
||||
}
|
||||
67
api-gateway/src/controllers/buscar.controller.test.ts
Normal file
67
api-gateway/src/controllers/buscar.controller.test.ts
Normal file
@@ -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<string, string> = {}): 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)
|
||||
})
|
||||
})
|
||||
13
api-gateway/src/controllers/buscar.controller.ts
Normal file
13
api-gateway/src/controllers/buscar.controller.ts
Normal file
@@ -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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
|
||||
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) ----------
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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<void
|
||||
}
|
||||
|
||||
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
|
||||
}
|
||||
|
||||
/** GET /api/cotizaciones/mias — últimas 50 del vendedor autenticado (sub del JWT) */
|
||||
|
||||
@@ -47,6 +47,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
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([])
|
||||
|
||||
@@ -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<vo
|
||||
}
|
||||
const asignacion = await asignarCapturistaSiAplica(usuarioSub, resultado.id, body.nombre, 'leads')
|
||||
res.status(201).json({ id: resultado.id, estado: 'NUEVO', oportunidadId: null, asignacion })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_LEAD', 'lead', resultado.id)
|
||||
}
|
||||
|
||||
/** POST /api/afluencias { nombre, telefono?, ..., perfil completo } — Alta Afluencia (tablero Oportunidades) */
|
||||
@@ -620,6 +622,7 @@ export async function cambiarEstadoLeadEndpoint(req: Request, res: Response): Pr
|
||||
}
|
||||
}
|
||||
res.json({ itemId, estado: body.estado, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CAMBIAR_ESTADO', 'lead', itemId, body.estado)
|
||||
}
|
||||
|
||||
/** POST /api/leads/:itemId/descartar { motivo, subcategoria?, comentario?, fuente? } */
|
||||
@@ -651,6 +654,7 @@ export async function descartarLeadEndpoint(req: Request, res: Response): Promis
|
||||
}
|
||||
}
|
||||
res.json({ itemId, estado: body.fuente === 'lead' ? 'Descartado' : 'Perdido', motivo: body.motivo, motivoItemId })
|
||||
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'DESCARTAR_LEAD', 'lead', itemId, body.motivo)
|
||||
}
|
||||
|
||||
// ---------- Conversión lead → oportunidad (botón "Agendo" de Mis leads) ----------
|
||||
@@ -742,15 +746,18 @@ export async function convertirLeadEndpoint(req: Request, res: Response): Promis
|
||||
if (esIdLocal(itemId) || !saludMonday.estaDisponible()) {
|
||||
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { oportunidadId } = await convertirLead(mondayService, itemId, usuarioSub, body.fecha)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId, etapa: 'Agendo Cita' })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
|
||||
} catch (err) {
|
||||
if (!esFalloMonday(err)) throw err
|
||||
const oportunidad = await crmLocal.convertirLead(itemId, usuarioSub)
|
||||
res.status(201).json({ leadId: itemId, oportunidadId: oportunidad.leadId, etapa: 'Agendo Cita' })
|
||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CONVERTIR_LEAD', 'lead', itemId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
api-gateway/src/controllers/notas.controller.test.ts
Normal file
69
api-gateway/src/controllers/notas.controller.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
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<string, string>, 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([])
|
||||
})
|
||||
})
|
||||
23
api-gateway/src/controllers/notas.controller.ts
Normal file
23
api-gateway/src/controllers/notas.controller.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
const apartadoId = String(req.params['id'])
|
||||
const notas = await listarNotas(apartadoId)
|
||||
res.json(notas)
|
||||
}
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
41
api-gateway/src/controllers/push.controller.ts
Normal file
41
api-gateway/src/controllers/push.controller.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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() })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
52
api-gateway/src/controllers/reporte-mi-tablero.controller.ts
Normal file
52
api-gateway/src/controllers/reporte-mi-tablero.controller.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
95
api-gateway/src/services/auditoria.service.test.ts
Normal file
95
api-gateway/src/services/auditoria.service.test.ts
Normal file
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
49
api-gateway/src/services/auditoria.service.ts
Normal file
49
api-gateway/src/services/auditoria.service.ts
Normal file
@@ -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<string, unknown> = {}
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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:<id>:<fecha>`.
|
||||
*/
|
||||
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<Record<string, unknown>> {
|
||||
const vendedoresCache = await this.state.get(CLAVE_RR_VENDEDORES)
|
||||
|
||||
163
api-gateway/src/services/buscar.service.test.ts
Normal file
163
api-gateway/src/services/buscar.service.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
111
api-gateway/src/services/buscar.service.ts
Normal file
111
api-gateway/src/services/buscar.service.ts
Normal file
@@ -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<BuscadoItem[]> {
|
||||
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)
|
||||
}
|
||||
54
api-gateway/src/services/nota-apartado.service.test.ts
Normal file
54
api-gateway/src/services/nota-apartado.service.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
26
api-gateway/src/services/nota-apartado.service.ts
Normal file
26
api-gateway/src/services/nota-apartado.service.ts
Normal file
@@ -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<NotaApartado> {
|
||||
return prisma.notaApartado.create({
|
||||
data: { apartadoId, usuarioId, contenido },
|
||||
})
|
||||
}
|
||||
|
||||
export async function listarNotas(apartadoId: string): Promise<NotaApartado[]> {
|
||||
return prisma.notaApartado.findMany({
|
||||
where: { apartadoId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
140
api-gateway/src/services/push.service.test.ts
Normal file
140
api-gateway/src/services/push.service.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
87
api-gateway/src/services/push.service.ts
Normal file
87
api-gateway/src/services/push.service.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
|
||||
147
api-gateway/src/services/reporte-mi-tablero.service.test.ts
Normal file
147
api-gateway/src/services/reporte-mi-tablero.service.test.ts
Normal file
@@ -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)')
|
||||
})
|
||||
})
|
||||
311
api-gateway/src/services/reporte-mi-tablero.service.ts
Normal file
311
api-gateway/src/services/reporte-mi-tablero.service.ts
Normal file
@@ -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<string, number>()
|
||||
// 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<string, { total: number; convertidos: number }>()
|
||||
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<string, number>()
|
||||
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<MiTablero> {
|
||||
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<MetricasUso> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
40
apps/agente-ia/package-lock.json
generated
40
apps/agente-ia/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPassword(true)}
|
||||
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:text-[#111827]"
|
||||
className="rounded-md px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cambiar contraseña
|
||||
</h2>
|
||||
{exito ? (
|
||||
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-3 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
32
apps/agente-ia/src/components/DarkModeToggle.tsx
Normal file
32
apps/agente-ia/src/components/DarkModeToggle.tsx
Normal file
@@ -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 (
|
||||
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
|
||||
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -23,12 +23,12 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-wide">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
</p>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-usuario"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Usuario
|
||||
</label>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
21
apps/agente-ia/src/components/Skeleton.tsx
Normal file
21
apps/agente-ia/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-[#E2E8F0] dark:bg-[#334155] ${className}`} />
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<Skeleton className="h-4 w-1/3 mb-3" />
|
||||
<Skeleton className="h-3 w-2/3 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -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(
|
||||
<AuthGate>
|
||||
<ChatPage />
|
||||
</AuthGate>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
<div className="mx-auto flex h-14 w-full max-w-3xl items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<span className="text-sm tracking-wide">
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]"> · </span>
|
||||
<span className="font-light text-[#64748B]">ASISTENTE IA</span>
|
||||
<span className="font-light text-[#64748B] dark:text-[#94A3B8]">ASISTENTE IA</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuevaConversacion}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Nueva conversación
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<DarkModeToggle />
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuevaConversacion}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Nueva conversación
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -117,11 +122,11 @@ export default function ChatPage() {
|
||||
|
||||
{mensajes.length === 0 && !escribiendo ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 text-center shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="w-full max-w-lg rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-center shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Asistente interno
|
||||
</p>
|
||||
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827]">
|
||||
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827] dark:text-[#E2E8F0]">
|
||||
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
|
||||
venta o las políticas de Frasa.
|
||||
</p>
|
||||
@@ -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}
|
||||
</button>
|
||||
@@ -149,11 +154,11 @@ export default function ChatPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="sticky bottom-0 border-t border-[#E5E7EB] bg-white">
|
||||
<footer className="sticky bottom-0 border-t border-[#E5E7EB] dark:border-[#1E293B] bg-white">
|
||||
<div className="relative mx-auto w-full max-w-3xl px-4 sm:px-6">
|
||||
{escribiendo && (
|
||||
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
|
||||
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] shadow-md">
|
||||
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] shadow-md">
|
||||
Escribiendo…
|
||||
</p>
|
||||
</div>
|
||||
@@ -175,7 +180,7 @@ export default function ChatPage() {
|
||||
onChange={(e) => setEntrada(e.target.value)}
|
||||
placeholder="Escribe tu pregunta…"
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm placeholder:text-[#94A3B8] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
38
apps/alta-clientes/package-lock.json
generated
38
apps/alta-clientes/package-lock.json
generated
@@ -12,10 +12,12 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"tesseract.js": "^7.0.0",
|
||||
@@ -2155,7 +2157,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -2452,6 +2453,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",
|
||||
@@ -2982,6 +2992,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/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -3276,6 +3295,23 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"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-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"tesseract.js": "^7.0.0",
|
||||
|
||||
@@ -27,9 +27,9 @@ interface Props {
|
||||
const emailSchema = z.string().trim().email()
|
||||
|
||||
const inputClase =
|
||||
'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'
|
||||
'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 labelClase = 'mb-1 block text-xs font-medium text-[#4B5563]'
|
||||
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
interface Errores {
|
||||
nombre?: string
|
||||
@@ -222,7 +222,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
@@ -244,7 +244,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
) : (
|
||||
<div className="mb-6 border-b border-[#E5E7EB] pb-6">
|
||||
<BusquedaLead onSeleccion={prellenarDesdeLead} />
|
||||
<p className="mt-2 text-xs text-[#64748B]">
|
||||
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos.
|
||||
</p>
|
||||
</div>
|
||||
@@ -273,7 +273,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
aria-label="Lada del teléfono"
|
||||
value={lada}
|
||||
onChange={(e) => setLada(e.target.value as 'MX' | 'US')}
|
||||
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 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"
|
||||
>
|
||||
<option value="MX">🇲🇽 +52</option>
|
||||
<option value="US">🇺🇸 +1</option>
|
||||
@@ -527,7 +527,7 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
||||
<button
|
||||
type="button"
|
||||
onClick={onVolver}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import DarkModeToggle from './DarkModeToggle'
|
||||
|
||||
interface Props {
|
||||
titulo: string
|
||||
@@ -8,19 +9,22 @@ interface Props {
|
||||
/** Barra superior de la app: marca a la izquierda, acciones a la derecha */
|
||||
export default function AppHeader({ titulo, acciones }: Props) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
<div className="mx-auto flex min-h-14 max-w-[1280px] flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2 sm:px-6">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827]">
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold text-[#111827]">{titulo}</h1>
|
||||
{acciones && <div className="ml-auto flex items-center gap-3">{acciones}</div>}
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{titulo}</h1>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<DarkModeToggle />
|
||||
{acciones}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<div className="mx-auto flex max-w-[1280px] items-center justify-between px-4 py-2 sm:px-6">
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPassword(true)}
|
||||
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] transition-colors duration-100 hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors duration-100 hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -86,19 +86,19 @@ export default function BotonOcrIne({ archivo, onAplicar }: Props) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">Lectura automática (OCR)</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Lectura automática (OCR)</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void leer()}
|
||||
disabled={estado.fase === 'procesando'}
|
||||
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-40"
|
||||
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-40"
|
||||
>
|
||||
{estado.fase === 'procesando' ? `Leyendo… ${estado.progreso}%` : 'Leer INE con OCR'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{estado.fase === 'procesando' && (
|
||||
<p className="mt-3 text-xs text-[#64748B]" role="status">
|
||||
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]" role="status">
|
||||
Reconociendo texto de la credencial… {estado.progreso}%
|
||||
</p>
|
||||
)}
|
||||
@@ -111,14 +111,14 @@ export default function BotonOcrIne({ archivo, onAplicar }: Props) {
|
||||
|
||||
{estado.fase === 'listo' && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-[#64748B]">
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Sugerencias detectadas — nada se sobreescribe sin tu confirmación:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{aVisibles(estado.sugerencias).map((s) => (
|
||||
<li key={s.clave} className="flex items-baseline justify-between gap-4 text-sm">
|
||||
<span className="text-[#111827]">
|
||||
<span className="mr-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">
|
||||
<span className="mr-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
{s.etiqueta}
|
||||
</span>
|
||||
{s.valor}
|
||||
@@ -130,7 +130,7 @@ export default function BotonOcrIne({ archivo, onAplicar }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => aplicar(s)}
|
||||
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Aplicar
|
||||
</button>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function BusquedaLead({ onSeleccion }: Props) {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="busqueda-lead"
|
||||
className="mb-1 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Buscar lead (nombre, teléfono o email)
|
||||
</label>
|
||||
@@ -56,29 +56,29 @@ export default function BusquedaLead({ onSeleccion }: Props) {
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Escribe al menos 2 caracteres…"
|
||||
className="w-full max-w-md rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
className="w-full max-w-md rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm text-[#111827] dark:text-[#E2E8F0] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
|
||||
{buscando && <p className="mt-3 text-xs text-[#64748B]">Buscando…</p>}
|
||||
{buscando && <p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">Buscando…</p>}
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!buscando && !error && resultados !== null && resultados.length === 0 && (
|
||||
<p className="mt-3 text-xs text-[#64748B]">Sin leads que coincidan.</p>
|
||||
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">Sin leads que coincidan.</p>
|
||||
)}
|
||||
|
||||
{resultados !== null && resultados.length > 0 && (
|
||||
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<ul className="mt-3 max-w-md divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
{resultados.map((lead) => (
|
||||
<li key={`${lead.fuente}-${lead.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSeleccion(lead)}
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
<span className="flex items-baseline gap-2 text-sm text-[#111827]">
|
||||
<span className="flex items-baseline gap-2 text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
{lead.nombre}
|
||||
{/* Insignia de procedencia: gris = archivo histórico, azul = modelo nuevo */}
|
||||
<span
|
||||
@@ -91,7 +91,7 @@ export default function BusquedaLead({ onSeleccion }: Props) {
|
||||
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-[#64748B]">
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{lead.telefono || 'sin teléfono'}
|
||||
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||
</span>
|
||||
|
||||
@@ -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-4 py-3 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-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 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
|
||||
@@ -45,8 +45,8 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
aria-modal="true"
|
||||
aria-label="Cambiar contraseña"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cambiar contraseña
|
||||
</h2>
|
||||
{exito ? (
|
||||
@@ -92,7 +92,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
required
|
||||
className={CLASE_INPUT}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-[#64748B]">Mínimo 8 caracteres y un número</p>
|
||||
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">Mínimo 8 caracteres y un número</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
|
||||
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-3 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -8,9 +8,9 @@ export interface CampoBaseProps {
|
||||
}
|
||||
|
||||
const inputClase =
|
||||
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm uppercase transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
'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 uppercase transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'
|
||||
|
||||
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563]'
|
||||
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
function Etiqueta({ etiqueta, requerido }: { etiqueta: string; requerido?: boolean }) {
|
||||
return (
|
||||
@@ -136,7 +136,7 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
|
||||
aria-label="Lada"
|
||||
value={lada}
|
||||
onChange={(e) => actualizar(e.target.value, numero)}
|
||||
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
className="w-28 shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 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"
|
||||
>
|
||||
<option value="+52">🇲🇽 +52</option>
|
||||
<option value="+1">🇺🇸 +1</option>
|
||||
|
||||
@@ -12,15 +12,15 @@ export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props)
|
||||
return (
|
||||
<section
|
||||
aria-label="Checklist del expediente"
|
||||
className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0"
|
||||
className="border-t border-[#E5E7EB] dark:border-[#1E293B] px-6 py-6 first:border-t-0"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Expediente: {estado.completos}/{estado.total}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{estado.items.map((item) => (
|
||||
<li key={item.clave} className="flex items-baseline justify-between gap-4 text-sm">
|
||||
<span className="text-[#111827]">{item.etiqueta}</span>
|
||||
<span className="text-[#111827] dark:text-[#E2E8F0]">{item.etiqueta}</span>
|
||||
{item.completo ? (
|
||||
<span className="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700">✓</span>
|
||||
) : (
|
||||
|
||||
32
apps/alta-clientes/src/components/DarkModeToggle.tsx
Normal file
32
apps/alta-clientes/src/components/DarkModeToggle.tsx
Normal file
@@ -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 (
|
||||
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
|
||||
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -25,14 +25,14 @@ function Miniatura({ documento, url }: { documento: DocumentoCargado; url: strin
|
||||
rel="noreferrer"
|
||||
title={`Abrir ${documento.nombre} en una pestaña nueva`}
|
||||
aria-label={`Abrir ${documento.nombre} en una pestaña nueva`}
|
||||
className="block h-20 w-20 shrink-0 overflow-hidden rounded-lg border border-[#E5E7EB] bg-[#F8FAFC]"
|
||||
className="block h-20 w-20 shrink-0 overflow-hidden rounded-lg border border-[#E5E7EB] bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
{esImagen ? (
|
||||
<img src={url} alt={`Vista previa de ${documento.nombre}`} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full flex-col items-center justify-center gap-1">
|
||||
<span className="text-xs font-semibold text-[#111827]">PDF</span>
|
||||
<span className="max-w-full truncate px-1 text-[10px] text-[#64748B]">{documento.nombre}</span>
|
||||
<span className="text-xs font-semibold text-[#111827] dark:text-[#E2E8F0]">PDF</span>
|
||||
<span className="max-w-full truncate px-1 text-[10px] text-[#64748B] dark:text-[#94A3B8]">{documento.nombre}</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
@@ -80,8 +80,8 @@ export default function FileUploader({ slot, etiqueta, documento, onAgregar, onQ
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{urlObjeto && <Miniatura documento={documento} url={urlObjeto} />}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{etiqueta}</p>
|
||||
<p className="mt-1 truncate text-sm text-[#111827]">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
||||
<p className="mt-1 truncate text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
{documento.nombre} · {formatoTamano(documento.tamano)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -101,16 +101,16 @@ export default function FileUploader({ slot, etiqueta, documento, onAgregar, onQ
|
||||
<div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`cursor-pointer rounded-xl border border-dashed border-[#D1D5DB] bg-white px-4 py-6 text-center transition-colors duration-200 ${
|
||||
className={`cursor-pointer rounded-xl border border-dashed border-[#D1D5DB] bg-white px- dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] 4 py-6 text-center transition-colors duration-200 ${
|
||||
isDragActive ? 'border-accent bg-accent/5' : 'hover:border-accent'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} aria-label={`Seleccionar archivo para ${etiqueta}`} />
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{etiqueta}</p>
|
||||
<p className="mt-2 text-xs text-[#64748B]">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
||||
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Seleccionar archivo
|
||||
</button>
|
||||
|
||||
@@ -23,10 +23,10 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></p>
|
||||
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B]">
|
||||
<p className="text-lg font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">FRASA<span className="text-[#F97316]">.</span></p>
|
||||
<p className="mt-2 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
</p>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-usuario"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Usuario
|
||||
</label>
|
||||
@@ -46,13 +46,13 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-4 py-3 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-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
@@ -63,7 +63,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-4 py-3 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-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-3 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -9,10 +9,10 @@ interface Props {
|
||||
/** Sección interna del paso del wizard (separada por divisor dentro de la tarjeta del paso) */
|
||||
export default function SectionCard({ titulo, descripcion, children }: Props) {
|
||||
return (
|
||||
<section className="border-t border-[#E5E7EB] px-6 py-6 first:border-t-0">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</h3>
|
||||
{descripcion && <p className="mt-2 text-sm text-[#64748B]">{descripcion}</p>}
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
|
||||
<section className="border-t border-[#E5E7EB] dark:border-[#1E293B] px-6 py-6 first:border-t-0">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</h3>
|
||||
{descripcion && <p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">{descripcion}</p>}
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function SignaturePad({ onFirma }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={limpiar}
|
||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Limpiar firma
|
||||
</button>
|
||||
|
||||
21
apps/alta-clientes/src/components/Skeleton.tsx
Normal file
21
apps/alta-clientes/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-[#E2E8F0] dark:bg-[#334155] ${className}`} />
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<Skeleton className="h-4 w-1/3 mb-3" />
|
||||
<Skeleton className="h-3 w-2/3 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,10 +27,10 @@ export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
||||
return (
|
||||
<>
|
||||
{/* Móvil: barra horizontal de progreso */}
|
||||
<div className="lg:hidden">
|
||||
<div className="md:hidden">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<p className="text-sm font-medium text-[#111827]">{pasos[pasoActual]}</p>
|
||||
<p className="text-xs text-[#64748B]">
|
||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{pasos[pasoActual]}</p>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Paso {pasoActual + 1} de {pasos.length}
|
||||
</p>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@ export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Escritorio: lista vertical con el estado de cada paso */}
|
||||
<nav aria-label="Progreso" className="hidden lg:block">
|
||||
<nav aria-label="Progreso" className="hidden md:block">
|
||||
<ol className="space-y-1">
|
||||
{pasos.map((nombre, i) => {
|
||||
const activo = i === pasoActual
|
||||
@@ -59,7 +59,7 @@ export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
|
||||
activo
|
||||
? 'bg-accent/5 font-semibold text-accent'
|
||||
: completado
|
||||
? 'text-[#111827] hover:bg-[#F1F5F9]'
|
||||
? 'text-[#111827] dark:text-[#E2E8F0] hover:bg-[#F1F5F9]'
|
||||
: 'text-[#94A3B8]'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
/** Botones de navegación del wizard */
|
||||
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
|
||||
return (
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-4 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4">
|
||||
<div className="sticky bottom-0 flex flex-col-reverse items-center justify-between gap-3 rounded-b-xl border-t border-[#E5E7EB] bg-white px-6 py-4 sm:flex-row">
|
||||
{onAnterior ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
@@ -26,5 +27,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import AppHeader from '../components/AppHeader'
|
||||
import { SkeletonCard } from "../components/Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
import BannerModoLocal from '../components/BannerModoLocal'
|
||||
import Stepper from '../components/Stepper'
|
||||
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
||||
@@ -67,7 +69,7 @@ const formatoFechaHora = (iso: string): string =>
|
||||
|
||||
/** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */
|
||||
function TarjetaPaso({ children }: { children: ReactNode }) {
|
||||
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm">{children}</div>
|
||||
return <div className="rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">{children}</div>
|
||||
}
|
||||
|
||||
/** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */
|
||||
@@ -226,6 +228,7 @@ export default function AltaClientePage() {
|
||||
const guardar = async () => {
|
||||
try {
|
||||
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
|
||||
toast.success("Borrador guardado")
|
||||
setBorradorId(b.id)
|
||||
setAvisoBorrador(
|
||||
`Borrador guardado ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`,
|
||||
@@ -283,6 +286,7 @@ export default function AltaClientePage() {
|
||||
const res = datos.progresoId
|
||||
? await completarProgreso(datos.progresoId, alta)
|
||||
: await crearAltaCliente(alta)
|
||||
toast.success("Alta de cliente completada")
|
||||
if (res.ok) {
|
||||
await subirDocumentos(res.id, alta)
|
||||
setRegistro(res.id)
|
||||
@@ -322,9 +326,9 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'cargando') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-16 sm:px-6">
|
||||
<p className="text-sm text-[#64748B]">Cargando…</p>
|
||||
<SkeletonCard />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
@@ -332,23 +336,23 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'entrada') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader titulo="Alta de Clientes" />
|
||||
<BannerModoLocal />
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<p className="text-sm text-[#64748B]">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.
|
||||
</p>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPantalla('afluencia')}
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
>
|
||||
<IconoIniciales iniciales="AA" />
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta Afluencia</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B]">
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Alta Afluencia</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Captura rápida del interesado; entra al embudo de ventas.
|
||||
</span>
|
||||
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
||||
@@ -359,11 +363,11 @@ export default function AltaClientePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPantalla('picker')}
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
||||
>
|
||||
<IconoIniciales iniciales="AC" />
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827]">Alta de Cliente</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B]">
|
||||
<span className="mt-4 block text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Alta de Cliente</span>
|
||||
<span className="mt-2 block text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Formato completo para quien aparta.
|
||||
</span>
|
||||
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
||||
@@ -380,14 +384,14 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'afluencia') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader titulo="Alta Afluencia" />
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<p className="text-sm text-[#64748B]">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Captura rápida del interesado; entra al embudo de ventas.
|
||||
</p>
|
||||
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<AltaAfluenciaForm
|
||||
valoresIniciales={afluenciaPrevia?.valores}
|
||||
oportunidadId={afluenciaPrevia?.oportunidadId}
|
||||
@@ -406,22 +410,22 @@ export default function AltaClientePage() {
|
||||
|
||||
if (pantalla === 'picker') {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader titulo="Alta de Clientes" />
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-8 sm:px-6 sm:py-10">
|
||||
<div className="max-w-3xl">
|
||||
{borradores.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
||||
{borradores.length === 1 ? '' : 's'}
|
||||
</h2>
|
||||
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
{borradores.map((b) => (
|
||||
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-[#111827]">{b.nombre}</p>
|
||||
<p className="mt-1 text-xs text-[#64748B]">
|
||||
<p className="truncate text-sm text-[#111827] dark:text-[#E2E8F0]">{b.nombre}</p>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -455,11 +459,11 @@ export default function AltaClientePage() {
|
||||
Empezar nuevo
|
||||
</button>
|
||||
|
||||
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] pt-8">
|
||||
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] dark:border-[#1E293B] pt-8">
|
||||
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Desde un lead de Monday
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
@@ -473,13 +477,13 @@ export default function AltaClientePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader
|
||||
titulo="Alta de Clientes"
|
||||
acciones={
|
||||
<>
|
||||
{avisoBorrador && (
|
||||
<span role="status" className="text-xs text-[#64748B]">
|
||||
<span role="status" className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{avisoBorrador}
|
||||
</span>
|
||||
)}
|
||||
@@ -491,7 +495,7 @@ export default function AltaClientePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void guardar()}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Guardar borrador
|
||||
</button>
|
||||
@@ -500,8 +504,8 @@ export default function AltaClientePage() {
|
||||
/>
|
||||
|
||||
<main className="mx-auto max-w-[1280px] px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="lg:grid lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-8">
|
||||
<div className="mb-6 lg:sticky lg:top-20 lg:mb-0 lg:self-start">
|
||||
<div className="md:grid md:grid-cols-[260px_minmax(0,1fr)] md:gap-8 lg:gap-8">
|
||||
<div className="mb-6 md:sticky md:top-20 md:mb-0 md:self-start">
|
||||
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
|
||||
</div>
|
||||
|
||||
@@ -648,23 +652,23 @@ export default function AltaClientePage() {
|
||||
</p>
|
||||
)}
|
||||
{registro && (
|
||||
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Firma en remoto
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
|
||||
documentos desde su celular, sin usuario ni contraseña.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generarLiga()}
|
||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Generar liga para firmar en remoto
|
||||
</button>
|
||||
{ligaUrl && (
|
||||
<p className="mt-4 break-all text-xs text-[#111827]">
|
||||
<p className="mt-4 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
|
||||
{ligaUrl}
|
||||
{ligaCopiada && (
|
||||
<span role="status" className="ml-2 font-medium text-accent">
|
||||
|
||||
@@ -82,9 +82,9 @@ export default function FirmaRemotaPage() {
|
||||
|
||||
if (errorCarga) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
|
||||
<main className="mx-auto max-w-xl px-4 py-24 text-center">
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<p role="alert" className="mt-8 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{errorCarga}
|
||||
</p>
|
||||
@@ -95,29 +95,29 @@ export default function FirmaRemotaPage() {
|
||||
|
||||
if (!cliente) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<main className="mx-auto max-w-xl px-4 py-24">
|
||||
<p className="text-sm text-[#64748B]">Cargando…</p>
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
|
||||
<header className="border-b border-[#E5E7EB] bg-white px-4 py-6">
|
||||
<div className="mx-auto flex max-w-xl items-baseline gap-3">
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<h1 className="text-2xl font-semibold text-[#111827]">Expediente digital</h1>
|
||||
<span className="text-sm font-bold tracking-widest text-[#111827] dark:text-[#E2E8F0]">FRASA<span className="text-[#F97316]">.</span></span>
|
||||
<h1 className="text-2xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Expediente digital</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-xl space-y-12 px-4 py-12">
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-[#111827]">
|
||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||
Hola, <span className="font-semibold">{cliente.nombre}</span>
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Proyecto {cliente.proyecto}
|
||||
{cliente.unidad ? ` · Unidad ${cliente.unidad}` : ''}. Firma y sube los documentos de tu
|
||||
expediente desde aquí.
|
||||
@@ -125,7 +125,7 @@ export default function FirmaRemotaPage() {
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="firma-titulo">
|
||||
<h3 id="firma-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h3 id="firma-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tu firma
|
||||
</h3>
|
||||
{firmaOk ? (
|
||||
@@ -149,7 +149,7 @@ export default function FirmaRemotaPage() {
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="docs-titulo">
|
||||
<h3 id="docs-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h3 id="docs-titulo" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tus documentos
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-4">
|
||||
@@ -157,14 +157,14 @@ export default function FirmaRemotaPage() {
|
||||
const subido = cliente.documentos.find((d) => d.tipo === slot)
|
||||
return (
|
||||
<li key={slot} className="rounded-xl border border-[#E5E7EB] bg-white px-4 py-3 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{etiqueta}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
|
||||
{subido ? (
|
||||
<p role="status" className="mt-2 text-sm text-[#111827]">
|
||||
<p role="status" className="mt-2 text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
✓ {subido.nombreArchivo}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<label className="inline-block cursor-pointer rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]">
|
||||
<label className="inline-block cursor-pointer rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]">
|
||||
{subiendoSlot === slot ? 'Subiendo…' : 'Seleccionar archivo'}
|
||||
<input
|
||||
type="file"
|
||||
@@ -178,7 +178,7 @@ export default function FirmaRemotaPage() {
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-2 text-xs text-[#64748B]">PDF/JPG/PNG · máx 10 MB</p>
|
||||
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">PDF/JPG/PNG · máx 10 MB</p>
|
||||
</div>
|
||||
)}
|
||||
{erroresDoc[slot] && (
|
||||
|
||||
37
apps/cobranza/package-lock.json
generated
37
apps/cobranza/package-lock.json
generated
@@ -12,9 +12,11 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
@@ -2301,6 +2303,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",
|
||||
@@ -2696,6 +2707,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",
|
||||
@@ -2888,6 +2908,23 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"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-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -17,17 +17,17 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||
<span className="text-xs font-medium text-[#64748B]">
|
||||
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPassword(true)}
|
||||
className="text-xs font-medium text-[#64748B] transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-colors duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -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
|
||||
@@ -45,8 +45,8 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
aria-modal="true"
|
||||
aria-label="Cambiar contraseña"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cambiar contraseña
|
||||
</h2>
|
||||
{exito ? (
|
||||
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
32
apps/cobranza/src/components/DarkModeToggle.tsx
Normal file
32
apps/cobranza/src/components/DarkModeToggle.tsx
Normal file
@@ -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 (
|
||||
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
|
||||
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -14,13 +14,13 @@ export default function KpiCard({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 ${
|
||||
className={`rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] ${
|
||||
plano ? '' : 'shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#64748B]">{detalle}</p>}
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{detalle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import DarkModeToggle from './DarkModeToggle'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<Link to="/" className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>
|
||||
<span className="text-[#F97316]">.</span>{' '}
|
||||
<span className="font-light text-[#64748B]">CLIENTES</span>
|
||||
<span className="font-light text-[#64748B] dark:text-[#94A3B8]">CLIENTES</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex items-center gap-3 text-sm sm:gap-6">
|
||||
<DarkModeToggle />
|
||||
<Link
|
||||
to="/"
|
||||
className="text-[#64748B] transition-colors duration-150 hover:text-[#111827]"
|
||||
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
Cobranza
|
||||
</Link>
|
||||
|
||||
@@ -23,12 +23,12 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-lg font-bold tracking-widest">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<p className="mt-2 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
</p>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-usuario"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Usuario
|
||||
</label>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -25,7 +25,7 @@ interface Props {
|
||||
onGuardar: (datos: AbonoForm) => void
|
||||
}
|
||||
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563]'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
/** Modal para registrar un abono (total o parcial) a un pago del plan */
|
||||
export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
@@ -62,13 +62,13 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="titulo-abono"
|
||||
className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-8 shadow-xl"
|
||||
className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl"
|
||||
>
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-[#111827]">
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-[#111827] dark:text-[#E2E8F0]">
|
||||
Registrar abono — Pago #{pago.numPago}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[#64748B]">
|
||||
Restante: <span className="font-bold text-[#111827]">{formatoMoneda(restante)}</span>
|
||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Restante: <span className="font-bold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(restante)}</span>
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
@@ -123,7 +123,7 @@ export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
type="button"
|
||||
onClick={onCancelar}
|
||||
aria-label="Cancelar y cerrar el registro de abono"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
21
apps/cobranza/src/components/Skeleton.tsx
Normal file
21
apps/cobranza/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-[#E2E8F0] dark:bg-[#334155] ${className}`} />
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<Skeleton className="h-4 w-1/3 mb-3" />
|
||||
<Skeleton className="h-3 w-2/3 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
@@ -28,5 +29,6 @@ createRoot(document.getElementById('root')!).render(
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
@@ -162,7 +163,7 @@ export default function DashboardPage() {
|
||||
return errorCarga ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
<SkeletonList count={5} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -205,7 +206,7 @@ export default function DashboardPage() {
|
||||
type="button"
|
||||
onClick={() => alternarOrden(columna)}
|
||||
aria-label={`Ordenar por ${titulo}`}
|
||||
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827]"
|
||||
className="inline-flex items-center gap-1 font-semibold uppercase tracking-wider transition-colors duration-100 ease-out hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
{titulo}
|
||||
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
@@ -216,7 +217,7 @@ export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Módulo Cobranza — Planes de enganche</h1>
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Módulo Cobranza — Planes de enganche</h1>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||
@@ -235,7 +236,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:gap-6 lg:grid-cols-4">
|
||||
<KpiCard titulo="Planes activos" valor={activos.length} />
|
||||
<KpiCard
|
||||
titulo="Al corriente / con atraso"
|
||||
@@ -247,9 +248,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Gráficas */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Al corriente vs vencidos
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
@@ -272,8 +273,8 @@ export default function DashboardPage() {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cobrado por mes (últimos 6 meses)
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
@@ -293,9 +294,9 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Tarjeta unificada: toolbar de filtros/búsqueda + tabla de planes */}
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 sm:gap-4 sm:px-5">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
|
||||
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */}
|
||||
<div className="flex rounded-lg border border-[#D1D5DB] bg-white" role="group" aria-label="Alcance de planes">
|
||||
<button
|
||||
@@ -303,7 +304,7 @@ export default function DashboardPage() {
|
||||
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
|
||||
aria-pressed={mios}
|
||||
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
|
||||
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] hover:text-[#111827]'
|
||||
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:text-[#E2E8F0]'
|
||||
}`}
|
||||
>
|
||||
Mis planes
|
||||
@@ -313,7 +314,7 @@ export default function DashboardPage() {
|
||||
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
|
||||
aria-pressed={!mios}
|
||||
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
|
||||
mios ? 'bg-white text-[#64748B] hover:text-[#111827]' : 'bg-accent text-white shadow-sm'
|
||||
mios ? 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:text-[#E2E8F0]' : 'bg-accent text-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
Todos
|
||||
@@ -328,7 +329,7 @@ export default function DashboardPage() {
|
||||
value={busqueda}
|
||||
onChange={(e) => actualizarParams({ q: e.target.value })}
|
||||
placeholder="Buscar por cliente o unidad…"
|
||||
className="w-64 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 sm:w-64"
|
||||
/>
|
||||
<label htmlFor="filtro-status" className="sr-only">
|
||||
Filtrar por status
|
||||
@@ -337,7 +338,7 @@ export default function DashboardPage() {
|
||||
id="filtro-status"
|
||||
value={filtroStatus}
|
||||
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="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="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"
|
||||
>
|
||||
<option value="TODOS">Todos los status</option>
|
||||
<option value="ACTIVO">ACTIVO</option>
|
||||
@@ -351,7 +352,7 @@ export default function DashboardPage() {
|
||||
id="filtro-proyecto"
|
||||
value={filtroProyecto}
|
||||
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="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="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"
|
||||
>
|
||||
<option value="TODOS">Todos los proyectos</option>
|
||||
{proyectos.map((p) => (
|
||||
@@ -366,7 +367,7 @@ export default function DashboardPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
<th scope="col" className="px-4 py-3">Proyecto</th>
|
||||
@@ -379,7 +380,7 @@ export default function DashboardPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{ordenados.map((d) => (
|
||||
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<tr key={d.plan.id} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
|
||||
{d.plan.clienteNombre}
|
||||
@@ -395,7 +396,7 @@ export default function DashboardPage() {
|
||||
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-[#64748B]">{formatoPorcentaje(d.avance)}</span>
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoPorcentaje(d.avance)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
|
||||
@@ -38,15 +39,15 @@ export default function HoyPage() {
|
||||
return errorCarga ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorCarga}</div>
|
||||
) : (
|
||||
<p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
<SkeletonList count={3} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||
</p>
|
||||
{resultado.origen === 'local' && (
|
||||
@@ -62,10 +63,10 @@ export default function HoyPage() {
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Teléfono</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
@@ -81,7 +82,7 @@ export default function HoyPage() {
|
||||
{items.map(({ plan, pago, atraso }) => (
|
||||
<tr
|
||||
key={`${plan.id}-${pago.id}`}
|
||||
className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]"
|
||||
className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="px-4 py-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from "react-hot-toast"
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
@@ -37,9 +38,9 @@ const esquema = z.object({
|
||||
type NuevoPlanForm = z.infer<typeof esquema>
|
||||
|
||||
const claseInput =
|
||||
'mt-1 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'
|
||||
'mt-1 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 claseError = 'mt-1 text-xs text-red-700'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563]'
|
||||
const claseLabel = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -80,6 +81,7 @@ export default function NuevoPlanPage() {
|
||||
try {
|
||||
const { plan } = await crearPlanAsync(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
toast.success("Plan creado correctamente")
|
||||
} catch (e) {
|
||||
setErrorGuardado(e instanceof Error ? e.message : 'Error al guardar el plan')
|
||||
} finally {
|
||||
@@ -88,16 +90,16 @@ export default function NuevoPlanPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<h1 className="text-xl font-semibold text-[#111827]">Nuevo plan de enganche</h1>
|
||||
<div className="mx-auto max-w-3xl space-y-6 px-4 sm:px-6">
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Nuevo plan de enganche</h1>
|
||||
|
||||
<form
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-8"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(guardar)(e)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="clienteNombre" className={claseLabel}>Cliente *</label>
|
||||
<input id="clienteNombre" type="text" className={claseInput} {...register('clienteNombre')} />
|
||||
@@ -177,12 +179,12 @@ export default function NuevoPlanPage() {
|
||||
{/* Vista previa del calendario */}
|
||||
{vistaPrevia && (
|
||||
<div className="mt-8 rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Vista previa — {vistaPrevia.pagos.length} pagos de {formatoMoneda(vistaPrevia.mensualidad)}
|
||||
</h2>
|
||||
<div className="mt-4 max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-[#64748B]">
|
||||
<thead className="text-[#64748B] dark:text-[#94A3B8]">
|
||||
<tr className="border-b border-[#D1D5DB]">
|
||||
<th scope="col" className="py-1">#</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
@@ -203,12 +205,12 @@ export default function NuevoPlanPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex items-center justify-end gap-3">
|
||||
<div className="mt-8 flex flex-col-reverse items-center justify-end gap-3 sm:flex-row">
|
||||
{errorGuardado && <p className="text-xs text-red-700">{errorGuardado}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function PlanDetallePage() {
|
||||
}, [id])
|
||||
|
||||
if (cargando) {
|
||||
return <p className="text-sm text-[#64748B]">Cargando...</p>
|
||||
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
|
||||
}
|
||||
|
||||
if (errorCarga) {
|
||||
@@ -72,8 +72,8 @@ export default function PlanDetallePage() {
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-16 text-center shadow-sm">
|
||||
<p className="text-[#64748B]">Plan no encontrado.</p>
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p- dark:border-[#1E293B] dark:bg-[#1E293B] 16 text-center shadow-sm">
|
||||
<p className="text-[#64748B] dark:text-[#94A3B8]">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
@@ -141,13 +141,13 @@ export default function PlanDetallePage() {
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-xl font-semibold text-[#111827]">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||
<span className="font-semibold text-[#111827]">{status}</span>
|
||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
|
||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#64748B]">
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Tel. {plan.clienteTelefono || '—'}
|
||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||
</p>
|
||||
@@ -169,10 +169,10 @@ export default function PlanDetallePage() {
|
||||
<div className="min-w-0 flex-1 space-y-6">
|
||||
{/* Resumen del plan */}
|
||||
<section
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm sm:p-6"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:p-6"
|
||||
aria-labelledby="titulo-resumen"
|
||||
>
|
||||
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-resumen" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Resumen del plan
|
||||
</h2>
|
||||
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
@@ -198,8 +198,8 @@ export default function PlanDetallePage() {
|
||||
{/* Barra de avance */}
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-[#64748B]">Avance del plan</span>
|
||||
<span className="font-semibold text-[#111827]">{formatoPorcentaje(avance)}</span>
|
||||
<span className="font-medium text-[#64748B] dark:text-[#94A3B8]">Avance del plan</span>
|
||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoPorcentaje(avance)}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-[#E5E7EB]">
|
||||
<div
|
||||
@@ -211,10 +211,10 @@ export default function PlanDetallePage() {
|
||||
</section>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">#</th>
|
||||
<th scope="col" className="px-4 py-3">Vencimiento</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Monto</th>
|
||||
@@ -234,7 +234,7 @@ export default function PlanDetallePage() {
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC]">
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td className="px-4 py-3 font-medium">{pago.numPago}</td>
|
||||
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
@@ -276,11 +276,11 @@ export default function PlanDetallePage() {
|
||||
</td>
|
||||
</tr>
|
||||
{expandido && (
|
||||
<tr className="bg-[#F8FAFC]">
|
||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<tr className="text-left font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="py-1">Fecha</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
<th scope="col" className="py-1">Referencia</th>
|
||||
@@ -289,7 +289,7 @@ export default function PlanDetallePage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB]">
|
||||
<tr key={a.id} className="border-t border-[#E5E7EB] dark:border-[#1E293B]">
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||
<td className="py-1">{a.referencia}</td>
|
||||
@@ -320,16 +320,16 @@ export default function PlanDetallePage() {
|
||||
{/* Rail derecho: acciones y bitácora */}
|
||||
<aside className="space-y-6 lg:sticky lg:top-20 lg:w-[340px] lg:shrink-0">
|
||||
<section
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm"
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
|
||||
aria-labelledby="titulo-acciones"
|
||||
>
|
||||
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<h2 id="titulo-acciones" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acciones
|
||||
</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
<button
|
||||
onClick={() => descargar(plan)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Estado de cuenta PDF
|
||||
</button>
|
||||
@@ -345,19 +345,19 @@ export default function PlanDetallePage() {
|
||||
</section>
|
||||
|
||||
{/* Bitácora del plan */}
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Bitácora
|
||||
</h2>
|
||||
{(plan.notas ?? []).length === 0 ? (
|
||||
<p className="mt-6 text-sm text-[#64748B]">Sin notas registradas.</p>
|
||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">Sin notas registradas.</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
{[...(plan.notas ?? [])]
|
||||
.sort((a, b) => b.fecha.localeCompare(a.fecha))
|
||||
.map((nota) => (
|
||||
<li key={nota.id} className="border-l-2 border-[#E5E7EB] pl-4">
|
||||
<p className="text-xs text-[#64748B]">
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{new Date(nota.fecha).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -366,7 +366,7 @@ export default function PlanDetallePage() {
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#111827]">{nota.texto}</p>
|
||||
<p className="mt-1 text-sm text-[#111827] dark:text-[#E2E8F0]">{nota.texto}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -380,7 +380,7 @@ export default function PlanDetallePage() {
|
||||
>
|
||||
<label
|
||||
htmlFor="nota-texto"
|
||||
className="block text-xs font-medium text-[#4B5563]"
|
||||
className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Nueva nota
|
||||
</label>
|
||||
@@ -390,7 +390,7 @@ export default function PlanDetallePage() {
|
||||
onChange={(e) => setNotaTexto(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Gestión realizada, acuerdos con el cliente…"
|
||||
className="mt-1 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="mt-1 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"
|
||||
/>
|
||||
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
|
||||
<div className="mt-4 flex justify-end">
|
||||
@@ -410,7 +410,7 @@ export default function PlanDetallePage() {
|
||||
{status === 'CANCELADO' && (
|
||||
<button
|
||||
onClick={() => void navigate('/')}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563EB" />
|
||||
<link rel="manifest" href="/quote/manifest.webmanifest" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/quote/icons/icon-192.png" />
|
||||
<title>FRASA Desarrollos — Cotizador</title>
|
||||
</head>
|
||||
|
||||
45
apps/cotizador/package-lock.json
generated
45
apps/cotizador/package-lock.json
generated
@@ -10,12 +10,15 @@
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"idb": "^8.0.3",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
@@ -2396,7 +2399,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/data-urls": {
|
||||
@@ -2767,6 +2769,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -2859,6 +2870,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -3352,6 +3369,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/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@@ -3775,6 +3801,23 @@
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"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-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
|
||||
@@ -13,12 +13,15 @@
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"idb": "^8.0.3",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^1.41.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
|
||||
12
apps/cotizador/public/manifest.json
Normal file
12
apps/cotizador/public/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "FRASA CRM",
|
||||
"short_name": "FRASA",
|
||||
"start_url": "/quote/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F8FAFC",
|
||||
"theme_color": "#2563EB",
|
||||
"icons": [
|
||||
{ "src": "/quote/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/quote/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
21
apps/cotizador/public/sw.js
Normal file
21
apps/cotizador/public/sw.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-env serviceworker */
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data ? event.data.json() : {}
|
||||
const titulo = data.titulo || 'FRASA'
|
||||
const cuerpo = data.cuerpo || ''
|
||||
const url = data.url || '/'
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(titulo, {
|
||||
body: cuerpo,
|
||||
data: { url },
|
||||
icon: '/quote/icons/icon-192.png',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = (event.notification.data && event.notification.data.url) || '/'
|
||||
event.waitUntil(clients.openWindow(url))
|
||||
})
|
||||
@@ -17,31 +17,31 @@ export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8FAFC] text-[#111827]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur">
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-[1280px] items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827]"
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] transition-all duration-100 ease-out hover:bg-[#F9FAFB] hover:text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
← Menú
|
||||
</a>
|
||||
<p className="text-sm text-[#111827]">
|
||||
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
<span className="font-bold">
|
||||
FRASA DESARROLLOS<span className="text-[#F97316]">.</span>
|
||||
</span>{' '}
|
||||
<span className="hidden font-normal text-[#64748B] sm:inline">— Cotizador</span>
|
||||
<span className="hidden font-normal text-[#64748B] dark:text-[#94A3B8] sm:inline">— Cotizador</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<span className="hidden text-xs font-medium text-[#64748B] md:inline">
|
||||
<span className="hidden text-xs font-medium text-[#64748B] dark:text-[#94A3B8] md:inline">
|
||||
{sesion.usuario.nombre} · {sesion.usuario.rol}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPassword(true)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Contraseña
|
||||
</button>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="busqueda-cliente" className="mb-2 block text-xs font-medium text-[#4B5563]">
|
||||
<label htmlFor="busqueda-cliente" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||
Buscar cliente en Monday (lead u oportunidad)
|
||||
</label>
|
||||
<input
|
||||
@@ -53,21 +53,21 @@ export function BuscadorCliente({ onSeleccion }: Props) {
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Nombre, teléfono o email…"
|
||||
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"
|
||||
/>
|
||||
|
||||
{buscando && <p className="mt-3 text-xs text-[#64748B]">Buscando…</p>}
|
||||
{buscando && <p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">Buscando…</p>}
|
||||
{error && (
|
||||
<p role="alert" className="mt-3 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!buscando && !error && resultados !== null && resultados.length === 0 && (
|
||||
<p className="mt-3 text-xs text-[#64748B]">Sin coincidencias.</p>
|
||||
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">Sin coincidencias.</p>
|
||||
)}
|
||||
|
||||
{resultados !== null && resultados.length > 0 && (
|
||||
<ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm">
|
||||
<ul className="mt-3 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -sm">
|
||||
{resultados.map((lead) => (
|
||||
<li key={`${lead.fuente}-${lead.id}`}>
|
||||
<button
|
||||
@@ -77,9 +77,9 @@ export function BuscadorCliente({ onSeleccion }: Props) {
|
||||
setQ('')
|
||||
setResultados(null)
|
||||
}}
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC]"
|
||||
className="flex w-full flex-wrap items-baseline justify-between gap-2 px-4 py-3 text-left transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
|
||||
>
|
||||
<span className="flex items-baseline gap-2 text-sm text-[#111827]">
|
||||
<span className="flex items-baseline gap-2 text-sm text-[#111827] dark:text-[#E2E8F0]">
|
||||
{lead.nombre}
|
||||
<span
|
||||
className={
|
||||
@@ -91,7 +91,7 @@ export function BuscadorCliente({ onSeleccion }: Props) {
|
||||
{lead.fuente === 'oportunidad' ? 'Oportunidad' : 'Lead'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-[#64748B]">
|
||||
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{lead.telefono || 'sin teléfono'}
|
||||
{lead.estado ? ` · ${lead.estado}` : ''}
|
||||
</span>
|
||||
|
||||
@@ -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:ring-2 focus:ring-accent/20 focus:outline-none'
|
||||
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:ring-2 focus:ring-accent/20 focus:outline-none'
|
||||
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
|
||||
@@ -45,8 +45,8 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
aria-modal="true"
|
||||
aria-label="Cambiar contraseña"
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
<h2 className="mb-6 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cambiar contraseña
|
||||
</h2>
|
||||
{exito ? (
|
||||
@@ -92,7 +92,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
required
|
||||
className={CLASE_INPUT}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-[#64748B]">Mínimo 8 caracteres y un número</p>
|
||||
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">Mínimo 8 caracteres y un número</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password-confirmar" className={CLASE_LABEL}>
|
||||
@@ -127,7 +127,7 @@ export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => voi
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 py-2.5 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2
|
||||
id="comparador-titulo"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||||
>
|
||||
Comparar propuestas
|
||||
</h2>
|
||||
@@ -85,18 +85,18 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
|
||||
aria-expanded={abierto}
|
||||
aria-controls="comparador-contenido"
|
||||
onClick={() => setAbierto((v) => !v)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
{abierto ? 'Ocultar comparación' : 'Comparar propuestas'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{abierto && (
|
||||
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#F8FAFC]">
|
||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<th scope="col" className="w-48 p-3 text-left align-bottom">
|
||||
<span className="sr-only">Concepto</span>
|
||||
</th>
|
||||
@@ -106,7 +106,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
|
||||
scope="col"
|
||||
className={`p-3 text-right align-bottom ${bordeColumna(i, 'inicio')}`}
|
||||
>
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827]">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
|
||||
{titulos[i] ?? p.tipo}
|
||||
</span>
|
||||
{i === ganador && (
|
||||
@@ -120,20 +120,20 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
|
||||
</thead>
|
||||
<tbody>
|
||||
{filas.map((fila, indiceFila) => (
|
||||
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC]">
|
||||
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<th
|
||||
scope="row"
|
||||
className="border-t border-[#E5E7EB] p-3 text-left text-xs font-medium text-[#64748B]"
|
||||
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
|
||||
>
|
||||
{fila.etiqueta}
|
||||
</th>
|
||||
{propuestas.map((p, i) => (
|
||||
<td
|
||||
key={p.tipo}
|
||||
className={`border-t border-[#E5E7EB] p-3 text-right whitespace-nowrap ${
|
||||
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
|
||||
fila.destacada
|
||||
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827]'}`
|
||||
: 'text-[#111827]'
|
||||
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'}`
|
||||
: 'text-[#111827] dark:text-[#E2E8F0]'
|
||||
} ${bordeColumna(
|
||||
i,
|
||||
indiceFila === filas.length - 1 ? 'fin' : 'media',
|
||||
@@ -147,7 +147,7 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-[#64748B]">
|
||||
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
El enganche ya incluye la deducción del apartado. El total pagado suma apartado +
|
||||
enganche + mensualidades + pago final.
|
||||
</p>
|
||||
|
||||
@@ -71,14 +71,14 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
|
||||
const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => (
|
||||
<div>
|
||||
<label htmlFor={id} className="mb-2 block text-xs font-medium text-[#4B5563]">
|
||||
<label htmlFor={id} className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||
{etiqueta}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
value={valor}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
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:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
>
|
||||
<option value="">Selecciona una unidad…</option>
|
||||
{LISTA_DESARROLLOS.map((dev) => {
|
||||
@@ -103,7 +103,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h2
|
||||
id="comparador-unidades-titulo"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[#64748B]"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||||
>
|
||||
Comparar unidades
|
||||
</h2>
|
||||
@@ -112,28 +112,28 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
aria-expanded={abierto}
|
||||
aria-controls="comparador-unidades-contenido"
|
||||
onClick={() => setAbierto((v) => !v)}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||
>
|
||||
{abierto ? 'Ocultar comparación' : 'Comparar 2 unidades'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{abierto && (
|
||||
<div id="comparador-unidades-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm">
|
||||
<div id="comparador-unidades-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{selector('comparador-a', codigoA, setCodigoA, 'Unidad A')}
|
||||
{selector('comparador-b', codigoB, setCodigoB, 'Unidad B')}
|
||||
</div>
|
||||
|
||||
{!comparacion ? (
|
||||
<p className="mt-6 text-sm text-[#64748B]">
|
||||
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
Selecciona dos unidades para ver la comparación lado a lado.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="w-full min-w-[480px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#F8FAFC]">
|
||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<th scope="col" className="w-48 p-3 text-left">
|
||||
<span className="sr-only">Concepto</span>
|
||||
</th>
|
||||
@@ -141,7 +141,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
<th
|
||||
key={`${c.desarrolloId}|${c.unidad.codigo}`}
|
||||
scope="col"
|
||||
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827]"
|
||||
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]"
|
||||
>
|
||||
{c.unidad.codigo}
|
||||
</th>
|
||||
@@ -150,18 +150,18 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
</thead>
|
||||
<tbody>
|
||||
{filas.map((fila) => (
|
||||
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC]">
|
||||
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<th
|
||||
scope="row"
|
||||
className="border-t border-[#E5E7EB] p-3 text-left text-xs font-medium text-[#64748B]"
|
||||
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
|
||||
>
|
||||
{fila.etiqueta}
|
||||
</th>
|
||||
{[comparacion.a, comparacion.b].map((c) => (
|
||||
<td
|
||||
key={`${c.desarrolloId}|${c.unidad.codigo}`}
|
||||
className={`border-t border-[#E5E7EB] p-3 text-right whitespace-nowrap ${
|
||||
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827]'
|
||||
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
|
||||
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'
|
||||
}`}
|
||||
>
|
||||
{fila.obtener(c)}
|
||||
@@ -171,7 +171,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mt-6 text-xs text-[#64748B]">
|
||||
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cada unidad se calcula con los parámetros de su desarrollo y el mismo apartado.
|
||||
El enganche ya incluye la deducción del apartado.
|
||||
</p>
|
||||
|
||||
32
apps/cotizador/src/components/DarkModeToggle.tsx
Normal file
32
apps/cotizador/src/components/DarkModeToggle.tsx
Normal file
@@ -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 (
|
||||
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
|
||||
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -23,12 +23,12 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 shadow-sm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-[#E5E7EB] bg-white p-8 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<p className="text-lg font-bold tracking-wide">
|
||||
FRASA<span className="text-[#F97316]">.</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-[#64748B]">
|
||||
<p className="mt-2 text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
|
||||
Acceso al ecosistema
|
||||
</p>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSes
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-usuario"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Usuario
|
||||
</label>
|
||||
@@ -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:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
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:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563]"
|
||||
className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
@@ -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:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
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:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user