feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "AplicacionPago" ADD COLUMN "capturadoPorId" TEXT;
-- CreateIndex
CREATE INDEX "AplicacionPago_capturadoPorId_idx" ON "AplicacionPago"("capturadoPorId");

View File

@@ -136,14 +136,18 @@ model Pago {
} }
model AplicacionPago { model AplicacionPago {
id String @id @default(uuid()) id String @id @default(uuid())
pagoId String pagoId String
pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade) pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade)
fechaPago DateTime fechaPago DateTime
monto Float monto Float
referencia String referencia String
// Usuario que capturó el abono (sub del JWT); null en abonos históricos o
// hechos sin auth (AUTH_ENABLED=false)
capturadoPorId String?
@@index([pagoId]) @@index([pagoId])
@@index([capturadoPorId])
} }
// Vinculación lead ↔ oportunidad del funnel: cada lead capturado entra al // Vinculación lead ↔ oportunidad del funnel: cada lead capturado entra al

View File

@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
import { ACCIONES_POR_ROL_DEFAULT } from '../src/config/acciones.ts' import { ACCIONES_POR_ROL_DEFAULT } from '../src/config/acciones.ts'
import { MODULOS } from '../src/config/roles.ts' import { MATRIZ, MODULOS } from '../src/config/roles.ts'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] }) dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
@@ -31,19 +31,8 @@ const CONFIGS_DEFAULT = [
{ clave: 'round_robin_habilitado', valor: 'false' }, { clave: 'round_robin_habilitado', valor: 'false' },
] ]
// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ────── // ── RolConfiguracion: módulos por rol (la MATRIZ de config/roles.ts es la
const MODULOS_VENDEDOR = [ // fuente de verdad; aquí solo se persiste para que /auth/me la lea de DB) ──
'mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat',
]
const MATRIZ_MODULOS = {
vendedor: MODULOS_VENDEDOR,
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
admin: [...MODULOS],
}
async function main() { async function main() {
console.log('⚙️ Sembrando ConfiguracionSistema...') console.log('⚙️ Sembrando ConfiguracionSistema...')
@@ -57,7 +46,7 @@ async function main() {
} }
console.log('\n⚙️ Sembrando RolConfiguracion...') console.log('\n⚙️ Sembrando RolConfiguracion...')
for (const [rol, modulos] of Object.entries(MATRIZ_MODULOS)) { for (const [rol, modulos] of Object.entries(MATRIZ)) {
const acciones = ACCIONES_POR_ROL_DEFAULT[rol] ?? ACCIONES_POR_ROL_DEFAULT.vendedor const acciones = ACCIONES_POR_ROL_DEFAULT[rol] ?? ACCIONES_POR_ROL_DEFAULT.vendedor
await prisma.rolConfiguracion.upsert({ await prisma.rolConfiguracion.upsert({
where: { rol }, where: { rol },

View File

@@ -0,0 +1,68 @@
/**
* VOCABULARIO canónico FRASA del backend: fases, estados, etapas, motivos,
* subcategorías, orígenes y desarrollos en un solo catálogo.
*
* Espejo de apps/mi-dia/src/lib/mi-dia.ts (mantener sincronizados). Los
* controllers siguen exportando sus constantes de validación zod derivadas de
* este mismo vocabulario:
* - leads.controller.ts → ESTADOS_LEAD_PERMITIDOS (FASES_LEAD sin NUEVO/DESCARTADO)
* - oportunidades.controller.ts → ETAPAS_OPORTUNIDAD_PERMITIDAS (= FASES_OPORTUNIDAD),
* MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA
* - apartados.controller.ts → FASES_APARTADO_PERMITIDAS (= FASES_APARTADO)
*/
export const VOCABULARIO = {
// Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del
// API + NUEVO (captura) y DESCARTADO (terminal)
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
// Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado')
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la
// columna status 'Estado'); incluye las fases de escrituración
FASES_APARTADO: [
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
'Venta',
'Seguimiento',
'Integracion',
'Llamada Escrituras',
'X Escriturar',
'Banco/Infonavit',
'X Autorizar',
'Autorizado',
'Escriturado',
'Titulado',
'Cancelo',
],
// Fases de escrituración (pipeline posterior a 'Firma Cliente')
FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'],
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA)
MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'],
// Subcategorías por categoría (catálogo del tablero Motivos No Compra,
// sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes'
// conservan las faltas de ortografía originales del tablero del cliente)
SUBCATEGORIAS: {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
},
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
} as const
export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number]
export type FaseEscrituracion = (typeof VOCABULARIO.FASES_ESCRITURACION)[number]
export type MotivoNoCompra = (typeof VOCABULARIO.MOTIVOS_NO_COMPRA)[number]

View File

@@ -12,7 +12,13 @@ export async function listarEndpoint(req: Request, res: Response): Promise<void>
const pagina = req.query.pagina ? Number(req.query.pagina) : undefined const pagina = req.query.pagina ? Number(req.query.pagina) : undefined
const limite = req.query.limite ? Number(req.query.limite) : undefined const limite = req.query.limite ? Number(req.query.limite) : undefined
const resultado = await clienteMaestroService.listar({ busqueda, desarrollo, estatus, pagina, limite }) const resultado = await clienteMaestroService.listar({
busqueda,
desarrollo,
estatus,
...(pagina !== undefined ? { pagina } : {}),
...(limite !== undefined ? { limite } : {}),
})
res.json(resultado) res.json(resultado)
} }

View File

@@ -110,7 +110,7 @@ describe('detalleContactoEndpoint (fallback local)', () => {
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled() expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown> const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' }) expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
expect(detalle['propiedad']).toBeNull() expect(detalle['propiedad']).toBeNull()
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1) expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
expect(Array.isArray(detalle['historial'])).toBe(true) expect(Array.isArray(detalle['historial'])).toBe(true)

View File

@@ -185,6 +185,7 @@ type MondayLeads = Pick<
| 'buscarLeadPorTelefono' | 'buscarLeadPorTelefono'
| 'buscarOportunidadPorTelefono' | 'buscarOportunidadPorTelefono'
| 'getLeads' | 'getLeads'
| 'getOportunidades'
| 'labelsDeColumna' | 'labelsDeColumna'
| 'obtenerNombreItem' | 'obtenerNombreItem'
| 'columnaIdPorTitulo' | 'columnaIdPorTitulo'

View File

@@ -12,9 +12,6 @@ const prismaMock = vi.hoisted(() => ({
leadCaptura: { leadCaptura: {
findMany: vi.fn(), findMany: vi.fn(),
}, },
leadOportunidad: {
findMany: vi.fn(),
},
cliente: { cliente: {
findMany: vi.fn(), findMany: vi.fn(),
}, },
@@ -36,7 +33,6 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock })) vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
import { resumenMiDiaEndpoint } from './mi-dia.controller.js' import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
import { saludMonday } from '../services/salud-monday.service.js'
const usuarioFake = { const usuarioFake = {
id: 'u-1', id: 'u-1',
@@ -80,12 +76,9 @@ const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
saludMonday.restablecer()
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake) prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
prismaMock.usuario.findMany.mockResolvedValue([]) prismaMock.usuario.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findMany.mockResolvedValue([]) prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.actividadLocal.findMany.mockResolvedValue([]) prismaMock.actividadLocal.findMany.mockResolvedValue([])
mondayMock.getUsuariosWorkspace.mockResolvedValue([]) mondayMock.getUsuariosWorkspace.mockResolvedValue([])
@@ -96,9 +89,8 @@ beforeEach(() => {
mondayMock.getUltimosCambios.mockResolvedValue(new Map()) mondayMock.getUltimosCambios.mockResolvedValue(new Map())
}) })
describe('resumenMiDiaEndpoint (modo local y unión)', () => { describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => { it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => {
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
prismaMock.leadCaptura.findMany.mockResolvedValue([ prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }), capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }), capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
@@ -109,15 +101,16 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
expect(mondayMock.getLeads).not.toHaveBeenCalled() expect(mondayMock.getLeads).not.toHaveBeenCalled()
expect(mondayMock.getOportunidades).not.toHaveBeenCalled() expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
expect(mondayMock.getApartados).not.toHaveBeenCalled()
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown> const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen["misLeads"] as unknown[]).length).toBe(1) // solo leads, no oportunidades expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1) expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)
expect(resumen['misActividadesHoy']).toEqual([]) expect(resumen['misActividadesHoy']).toEqual([])
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 }) expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
}) })
it('Monday se cae a media lectura (502): cae al resumen local', async () => { it('las capturas locales llegan con pendienteSync (badge ⏳ del front)', async () => {
mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió'))
prismaMock.leadCaptura.findMany.mockResolvedValue([ prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }), capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
]) ])
@@ -126,41 +119,11 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
await resumenMiDiaEndpoint(reqFake, res) await resumenMiDiaEndpoint(reqFake, res)
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown> const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1']) expect((resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]).map((l) => l.id)).toEqual(['loc_l1'])
expect((resumen['misLeads'] as { pendienteSync?: boolean }[])[0]?.pendienteSync).toBe(true)
}) })
it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => { it('sin capturas: listas vacías y sin flags de sync', async () => {
mondayMock.getLeads.mockResolvedValue([
{
id: '9001',
nombre: 'Lead Monday',
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-02T10:00:00.000Z',
estado: 'Nuevo',
origen: 'Web',
telefono: '',
email: '',
vendedorIds: [123],
},
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }),
])
const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res)
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]
expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero
expect(leads[0]?.pendienteSync).toBe(true)
expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag
// Y la captura quedó marcada en Mis capturas
const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[]
expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' })
})
it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => {
const { res, json } = resFake() const { res, json } = resFake()
await resumenMiDiaEndpoint(reqFake, res) await resumenMiDiaEndpoint(reqFake, res)
@@ -170,8 +133,20 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
expect(JSON.stringify(resumen)).not.toContain('pendienteSync') expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
}) })
it('un error no-Monday se propaga (no se traga nada)', async () => { it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => {
mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug')) await resumenMiDiaEndpoint(reqFake, resFake().res)
expect(prismaMock.usuario.findMany).not.toHaveBeenCalled()
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' })
await resumenMiDiaEndpoint(reqFake, resFake().res)
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
})
it('un error de BD se propaga (no se traga nada)', async () => {
prismaMock.leadCaptura.findMany.mockRejectedValue(new AppError(500, 'bug'))
await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug') await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug')
}) })
}) })

View File

@@ -1,18 +1,15 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { mondayService } from '../services/monday.service.js' import { resumenMiDiaLocal, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
import { saludMonday } from '../services/salud-monday.service.js'
import { esFalloMonday } from '../services/sync-monday.service.js'
import { config } from '../config/index.js'
import { AppError } from '../utils/errors.js' import { AppError } from '../utils/errors.js'
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
/** /**
* GET /api/mi-dia/resumen — agenda del vendedor logueado. * GET /api/mi-dia/resumen — agenda del vendedor logueado.
* El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday); * SIEMPRE responde desde datos locales (LeadCaptura / Cliente / ActividadLocal):
* sin mapeo, las listas personales van vacías con sinMapeoMonday: true. * Monday ya no participa en este endpoint. El "mío" se resuelve por el
* usuarioId de cada captura; admin/dirección ven todas.
*/ */
export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise<void> { export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise<void> {
// Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin // Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin
@@ -22,27 +19,6 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
: await prisma.usuario.findUnique({ where: { username: 'admin' } }) : await prisma.usuario.findUnique({ where: { username: 'admin' } })
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
// Nombres de los usuarios ya mapeados a Monday (para la sección equipo)
const mapeados = await prisma.usuario.findMany({
where: { mondayUserId: { not: null }, activo: true },
select: { nombre: true, mondayUserId: true },
})
const nombresPorMondayId = new Map<number, string>(
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
)
// TODOS los usuarios del workspace de Monday (el "último cambio" de un lead
// puede ser de alguien sin cuenta en el ecosistema); si ya estaba mapeado
// gana el nombre del ecosistema. Si Monday falla, el resumen sale igual.
try {
for (const u of await mondayService.getUsuariosWorkspace()) {
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
}
// Capturas de afluencia: admin ve todas, otros solo las suyas // Capturas de afluencia: admin ve todas, otros solo las suyas
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas' const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
const capturas = await prisma.leadCaptura.findMany({ const capturas = await prisma.leadCaptura.findMany({
@@ -50,92 +26,40 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
// "Lo mío" incluye las oportunidades ligadas a mis capturas (mapping
// LeadOportunidad: conversión con "Agendo" y afluencias históricas)
const mappings = await prisma.leadOportunidad.findMany({
where: { leadId: { in: capturas.map((c) => c.leadId) } },
})
const capturadosIds = new Set([
...capturas.map((c) => c.leadId),
...capturas.map((c) => c.oportunidadId).filter((id): id is string => id !== null),
...mappings.map((m) => m.oportunidadId),
])
// Altas de clientes: admin ve todas, otros solo las suyas // Altas de clientes: admin ve todas, otros solo las suyas
const clientes = await prisma.cliente.findMany({ const clientes = await prisma.cliente.findMany({
where: esAdmin ? {} : { capturadoPorId: usuario.id }, where: esAdmin ? {} : { capturadoPorId: usuario.id },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
// Actividades locales del usuario
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id,
titulo: a.titulo,
descripcion: a.descripcion,
usuarioId: a.usuarioId,
leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento,
fechaCierre: a.fechaCierre,
estado: a.estado,
tipo: a.tipo,
}))
// Usuarios activos (para la sección equipo, solo coordinador/admin)
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId } const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
if (!saludMonday.estaDisponible()) {
// Actividades locales del usuario
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id,
titulo: a.titulo,
descripcion: a.descripcion,
usuarioId: a.usuarioId,
leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento,
fechaCierre: a.fechaCierre,
estado: a.estado,
tipo: a.tipo,
}))
// Usuarios activos (para la sección equipo, solo coordinador/admin)
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
return
}
try {
const resumen = await resumenMiDia(
mondayService,
usuarioMiDia,
nombresPorMondayId,
capturas,
clientes,
new Date(),
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
capturadosIds,
)
// Unión: capturas locales aún no sincronizadas se agregan con badge ⏳
res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre))
} catch (err) {
// Monday se cayó a media lectura: mismo resumen, pero todo local
if (!esFalloMonday(err)) throw err
const actividadesRaw = await prisma.actividadLocal.findMany({
where: { usuarioId: usuario.id },
orderBy: { createdAt: 'desc' },
})
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
id: a.id, titulo: a.titulo, descripcion: a.descripcion,
usuarioId: a.usuarioId, leadCapturaId: a.leadCapturaId,
fechaVencimiento: a.fechaVencimiento, fechaCierre: a.fechaCierre,
estado: a.estado, tipo: a.tipo,
}))
let usuarios: UsuarioLocal[] = []
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
const usuariosRaw = await prisma.usuario.findMany({
where: { activo: true },
select: { id: true, nombre: true, rol: true, activo: true },
})
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
}
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
}
} }

View File

@@ -259,6 +259,8 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
fechaPago: new Date(body.fechaPago), fechaPago: new Date(body.fechaPago),
monto: aplicacion.monto, monto: aplicacion.monto,
referencia: aplicacion.referencia, referencia: aplicacion.referencia,
// Quien capturó el abono: sub del JWT (null si AUTH_ENABLED=false o token sin sub)
capturadoPorId: req.usuarioToken?.sub ?? null,
}, },
}), }),
prisma.pago.update({ prisma.pago.update({

View File

@@ -90,7 +90,12 @@ export function crearController(svc: RecordatorioVendedorService) {
const usuarioId = req.usuarioToken?.sub const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida') if (!usuarioId) throw new Error('Auth requerida')
const completadosStr = req.query['completados'] as string | undefined const completadosStr = req.query['completados'] as string | undefined
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = { const opts: {
completados?: boolean
desde?: string | null
hasta?: string | null
clienteClave?: string | null
} = {
desde: (req.query['desde'] as string) ?? null, desde: (req.query['desde'] as string) ?? null,
hasta: (req.query['hasta'] as string) ?? null, hasta: (req.query['hasta'] as string) ?? null,
} }

View File

@@ -8,9 +8,11 @@ import { resumenMora } from '../services/mora.service.js'
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */ /** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio) export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */ /** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta
export async function resumenReportes(_req: Request, res: Response): Promise<void> { * ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */
res.json(await reportesService.resumen()) export async function resumenReportes(req: Request, res: Response): Promise<void> {
const dias = Number(req.query['dias'])
res.json(await reportesService.resumen(Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined))
} }
/** GET /api/reportes/scoring — scores de todos los leads activos */ /** GET /api/reportes/scoring — scores de todos los leads activos */

View File

@@ -92,7 +92,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
rol: 'vendedor', rol: 'vendedor',
asignacion: 'MONTEBELLO', asignacion: 'MONTEBELLO',
mondayUserId: 13676, mondayUserId: 13676,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'], modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
acciones: [], acciones: [],
}) })
}) })

View File

@@ -54,7 +54,7 @@ describe('login', () => {
rol: 'vendedor', rol: 'vendedor',
asignacion: 'MONTEBELLO', asignacion: 'MONTEBELLO',
mondayUserId: null, mondayUserId: null,
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'], modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
acciones: [], acciones: [],
}) })
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos') expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
@@ -103,7 +103,7 @@ describe('me', () => {
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => { it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' })) prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' }) const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat']) expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'])
}) })
it('rechaza si el usuario ya no existe o está inactivo', async () => { it('rechaza si el usuario ya no existe o está inactivo', async () => {

View File

@@ -602,7 +602,7 @@ export class AutomatizacionesService {
pendienteSync: false, pendienteSync: false,
}, },
}) })
} catch (e) { console.error('[APARTADO-ERR]', e.message?.slice(0,150)) } } catch (e) { console.error('[APARTADO-ERR]', e instanceof Error ? e.message.slice(0, 150) : e) }
} catch (err) { } catch (err) {
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) }) resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
} }

View File

@@ -68,7 +68,8 @@ describe('detalleContacto (GET /api/contactos/:id/detalle)', () => {
}, },
) )
expect(d).not.toBeNull() expect(d).not.toBeNull()
expect(d!.contacto['CURP']).toBe('XAXX010101XXXXXXX0') // El contacto del detalle se devuelve con campos nombrados del modelo local
expect(d!.contacto['curp']).toBe('XAXX010101XXXXXXX0')
// Propiedad comprada (apartado relacionado, enriquecido con el desarrollo) // Propiedad comprada (apartado relacionado, enriquecido con el desarrollo)
expect(d!.propiedad).toMatchObject({ expect(d!.propiedad).toMatchObject({
unidadCodigo: 'SENDA-409-1-2', unidadCodigo: 'SENDA-409-1-2',

View File

@@ -31,7 +31,7 @@ export interface EventoHistorialContacto {
} }
export interface DetalleContacto { export interface DetalleContacto {
contacto: Record<string, string> // columnas del board por título ('nombre' incluido) contacto: Record<string, string> // campos nombrados del modelo local (nombre, telefono, email, …)
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */ /** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
propiedad: { propiedad: {
id: string id: string
@@ -57,6 +57,32 @@ export function claveTelefono(telefono: string): string {
return digitos.length >= 10 ? digitos.slice(-10) : '' return digitos.length >= 10 ? digitos.slice(-10) : ''
} }
/**
* Mapeo título de columna del board Contactos (Monday) → campo nombrado del
* modelo local. Los títulos sin entrada se conservan tal cual.
*/
const CAMPOS_CONTACTO: Record<string, string> = {
nombre: 'nombre',
'Teléfono': 'telefono',
Email: 'email',
Estado: 'estado',
CURP: 'curp',
RFC: 'rfc',
'Estado Civil': 'estadoCivil',
'Fecha Nacimiento': 'fechaNacimiento',
'Actividad Economica': 'actividadEconomica',
'Dirección': 'direccion',
}
/** Normaliza un record por título de columna a campos nombrados del modelo local */
export function normalizarCamposContacto(contacto: Record<string, string>): Record<string, string> {
const normalizado: Record<string, string> = {}
for (const [titulo, valor] of Object.entries(contacto)) {
normalizado[CAMPOS_CONTACTO[titulo] ?? titulo] = valor
}
return normalizado
}
type MondayDetalle = Pick< type MondayDetalle = Pick<
MondayService, MondayService,
| 'getContactoDetalle' | 'getContactoDetalle'
@@ -169,7 +195,7 @@ export async function detalleContacto(
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO)) ].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
return { return {
contacto, contacto: normalizarCamposContacto(contacto),
propiedad, propiedad,
relaciones: { relaciones: {
leads: relLeads.map((l) => ({ leads: relLeads.map((l) => ({

View File

@@ -162,10 +162,11 @@ describe('detalleContactoLocal', () => {
expect(detalle.contacto).toMatchObject({ expect(detalle.contacto).toMatchObject({
nombre: 'JUAN PÉREZ', nombre: 'JUAN PÉREZ',
Teléfono: '4771234567', telefono: '4771234567',
Email: 'juan@correo.mx', email: 'juan@correo.mx',
CURP: 'CURP123', estado: 'Cliente',
RFC: 'RFC123', curp: 'CURP123',
rfc: 'RFC123',
}) })
expect(detalle.propiedad).toMatchObject({ expect(detalle.propiedad).toMatchObject({
id: 'loc_a1', id: 'loc_a1',
@@ -203,7 +204,7 @@ describe('detalleContactoLocal', () => {
const detalle = await detalleContactoLocal('loc_1') const detalle = await detalleContactoLocal('loc_1')
expect(detalle).not.toBeNull() expect(detalle).not.toBeNull()
if (detalle === null) return if (detalle === null) return
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' }) expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877', estado: 'Afluencia' })
expect(detalle.propiedad).toBeNull() expect(detalle.propiedad).toBeNull()
expect(detalle.relaciones.oportunidades).toHaveLength(1) expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.historial.length).toBeGreaterThan(0) expect(detalle.historial.length).toBeGreaterThan(0)

View File

@@ -202,17 +202,17 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
cliente !== null cliente !== null
? { ? {
nombre: cliente.nombreCompleto, nombre: cliente.nombreCompleto,
Teléfono: cliente.celular ?? '', telefono: cliente.celular ?? '',
Email: cliente.email ?? '', email: cliente.email ?? '',
CURP: cliente.curp, estado: 'Cliente',
RFC: cliente.rfc, curp: cliente.curp,
Estado: 'Cliente', rfc: cliente.rfc,
} }
: { : {
nombre: capturaBase!.nombreLead, nombre: capturaBase!.nombreLead,
Teléfono: capturaBase!.telefono ?? '', telefono: capturaBase!.telefono ?? '',
Email: capturaBase!.email ?? '', email: capturaBase!.email ?? '',
Estado: capturaBase!.etapa, estado: capturaBase!.etapa,
} }
return { contacto, propiedad, relaciones, historial } return { contacto, propiedad, relaciones, historial }

View File

@@ -218,7 +218,14 @@ export async function resumenMiDia(
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */ /** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
nombresPorMondayId: Map<number, string>, nombresPorMondayId: Map<number, string>,
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */ /** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [], capturas: {
leadId: string
nombreLead: string
telefono: string | null
createdAt: Date
/** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */
oportunidadId?: string | null
}[] = [],
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */ /** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
clientes: { clientes: {
id: string id: string
@@ -332,7 +339,10 @@ export async function resumenMiDia(
) )
// Set de oportunidades mías (por LeadCaptura o por vendedorIds) // Set de oportunidades mías (por LeadCaptura o por vendedorIds)
const miosOportunidadesIds = new Set( const miosOportunidadesIds = new Set(
capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId), capturas
.filter((c) => c.oportunidadId)
.map((c) => c.oportunidadId)
.filter((id): id is string => id != null),
) )
const miosApartadosTodos = apartados.filter((a) => const miosApartadosTodos = apartados.filter((a) =>
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id), esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
@@ -387,7 +397,8 @@ export async function resumenMiDia(
estado: a.estado, estado: a.estado,
fechaLimite: a.fechaLimite, fechaLimite: a.fechaLimite,
ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null, ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null,
pendienteSync: a.pendienteSync, // Los apartados leídos de Monday ya están sincronizados por definición
pendienteSync: false,
})) }))
const misApartados: ApartadoMiDia[] = miosApartados const misApartados: ApartadoMiDia[] = miosApartados
@@ -874,9 +885,9 @@ export function resumenMiDiaLocal(
.map((c) => ({ .map((c) => ({
id: c.leadId, id: c.leadId,
nombre: c.nombreLead, nombre: c.nombreLead,
unidadCodigo: '', unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa, estado: c.etapa,
fechaLimite: '', fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
ultimoCambio: null, ultimoCambio: null,
pendienteSync: c.pendienteSync, pendienteSync: c.pendienteSync,
})), })),

View File

@@ -1672,6 +1672,8 @@ export interface DatosOportunidadAfluencia {
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real) tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
calificaLabel?: string | undefined // dropdown 'Lead Califica' calificaLabel?: string | undefined // dropdown 'Lead Califica'
loQuierePara?: string | undefined // dropdown 'Proposito de Compra' loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
/** text '# Dependientes' (si el board aún no tiene la columna, Monday lo ignora) */
numDependientes?: string | undefined
notas?: string | undefined // long_text 'COMENTARIOS' notas?: string | undefined // long_text 'COMENTARIOS'
} }

View File

@@ -75,12 +75,14 @@ describe('construirMiTablero', () => {
captura({ board: 'OPORTUNIDADES' }), captura({ board: 'OPORTUNIDADES' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }), captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
captura({ board: 'APARTADO', etapa: 'CANCELADO' }), captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
] ]
const tablero = construirMiTablero(capturas, [], [], AHORA) const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.conversion.leads).toBe(2) expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
expect(tablero.conversion.oportunidades).toBe(1) expect(tablero.conversion.oportunidades).toBe(1)
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
expect(tablero.conversion.vendidos).toBe(1) expect(tablero.conversion.vendidos).toBe(1)
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
}) })
it('calcula ventas del mes correctamente', () => { it('calcula ventas del mes correctamente', () => {

View File

@@ -11,6 +11,8 @@ export interface MiTablero {
oportunidades: number oportunidades: number
apartados: number apartados: number
vendidos: number vendidos: number
/** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */
descartados: number
} }
ventasMes: { ventasMes: {
monto: number monto: number
@@ -125,6 +127,7 @@ export function construirMiTablero(
c.board === 'APARTADO' && c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()), ['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
).length ).length
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
// -- Ventas del mes -- // -- Ventas del mes --
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
@@ -183,7 +186,7 @@ export function construirMiTablero(
return { return {
generadoEn: ahora.toISOString(), generadoEn: ahora.toISOString(),
leadsPorDia, leadsPorDia,
conversion: { leads, oportunidades, apartados, vendidos }, conversion: { leads, oportunidades, apartados, vendidos, descartados },
ventasMes, ventasMes,
porOrigen, porOrigen,
porDesarrollo, porDesarrollo,
@@ -203,6 +206,7 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
const capturas = await prisma.leadCaptura.findMany({ const capturas = await prisma.leadCaptura.findMany({
where: { usuarioId }, where: { usuarioId },
select: { select: {
leadId: true,
board: true, board: true,
etapa: true, etapa: true,
origen: true, origen: true,
@@ -246,11 +250,11 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos) // Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
const whereUsuario = usuarioId ? { usuarioId } : undefined const usuarioFiltro = usuarioId ?? undefined
const [capturas, actividades, usuarios] = await Promise.all([ const [capturas, actividades, usuarios] = await Promise.all([
prisma.leadCaptura.findMany({ prisma.leadCaptura.findMany({
where: whereUsuario, ...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
select: { select: {
usuarioId: true, usuarioId: true,
etapa: true, etapa: true,
@@ -258,14 +262,14 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
}, },
}), }),
prisma.actividadLocal.findMany({ prisma.actividadLocal.findMany({
where: whereUsuario, ...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
select: { select: {
usuarioId: true, usuarioId: true,
estado: true, estado: true,
}, },
}), }),
prisma.usuario.findMany({ prisma.usuario.findMany({
where: whereUsuario ? { id: usuarioId } : { activo: true }, where: usuarioFiltro ? { id: usuarioFiltro } : { activo: true },
select: { select: {
id: true, id: true,
nombre: true, nombre: true,

View File

@@ -168,23 +168,38 @@ function inicioDelDia(d: Date): Date {
/** /**
* Construye el resumen a partir de los datos crudos. Función pura: toda la * Construye el resumen a partir de los datos crudos. Función pura: toda la
* lógica de conteo/tasas se prueba sin red ni base de datos. * lógica de conteo/tasas se prueba sin red ni base de datos.
*
* `dias` (opcional, 7/30/90) acota leads y oportunidades a las capturas del
* rango (por createdAt); inventario, cobranza y actividades son posición
* actual y no se filtran. Sin `dias` se cuenta TODO el histórico (igual que antes).
*/ */
export function construirResumen(datos: DatosResumen, ahora: Date): ResumenReportes { export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number): ResumenReportes {
const hoy = inicioDelDia(ahora) const hoy = inicioDelDia(ahora)
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000) const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
const rangoValido = dias !== undefined && Number.isFinite(dias) && dias > 0
const desdeRango = rangoValido
? new Date(hoy.getTime() - (Math.floor(dias!) - 1) * 24 * 60 * 60 * 1000)
: null
const enRango = (createdAt: string): boolean => {
if (!desdeRango) return true
const t = Date.parse(createdAt)
return !Number.isNaN(t) && t >= desdeRango.getTime()
}
const leadsRango = datos.leads.filter((l) => enRango(l.createdAt))
const oportunidadesRango = datos.oportunidades.filter((o) => enRango(o.createdAt))
const creadosEn = (desde: Date): number => const creadosEn = (desde: Date): number =>
datos.leads.filter((l) => { leadsRango.filter((l) => {
const t = Date.parse(l.createdAt) const t = Date.parse(l.createdAt)
return !Number.isNaN(t) && t >= desde.getTime() return !Number.isNaN(t) && t >= desde.getTime()
}).length }).length
const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa)) const porEtapa = contarPor(oportunidadesRango.map((o) => o.etapa))
const totalOps = datos.oportunidades.length const totalOps = oportunidadesRango.length
const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length const ganadas = oportunidadesRango.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
const apartadosOMas = datos.oportunidades.filter((o) => const apartadosOMas = oportunidadesRango.filter((o) =>
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()), ['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
).length ).length
const activas = datos.oportunidades.filter( const activas = oportunidadesRango.filter(
(o) => (o) =>
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) && !ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
o.etapa.trim().toUpperCase() !== 'GANADO', o.etapa.trim().toUpperCase() !== 'GANADO',
@@ -233,28 +248,28 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la // Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí) // afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
leads: { leads: {
total: datos.leads.length, total: leadsRango.length,
nuevosHoy: creadosEn(hoy), nuevosHoy: creadosEn(hoy),
nuevosSemana: creadosEn(hace7dias), nuevosSemana: creadosEn(hace7dias),
porEstado: contarPor(datos.leads.map((l) => l.estado)), porEstado: contarPor(leadsRango.map((l) => l.estado)),
porOrigen: contarPor(datos.leads.map((l) => l.origen)), porOrigen: contarPor(leadsRango.map((l) => l.origen)),
perfil: { perfil: {
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')), porDesarrollo: contarPorNoVacios(oportunidadesRango.map((o) => o.desarrollo ?? '')),
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')), porModelo: contarPorNoVacios(oportunidadesRango.map((o) => o.modeloInteres ?? '')),
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')), porEstadoCivil: contarPorNoVacios(oportunidadesRango.map((o) => o.estadoCivil ?? '')),
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')), porTipoEmpleo: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEmpleo ?? '')),
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')), porTipoEconomia: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEconomia ?? '')),
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')), porCalifica: contarPorNoVacios(oportunidadesRango.map((o) => o.califica ?? '')),
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')), porCredito: contarPorNoVacios(oportunidadesRango.map((o) => o.credito ?? '')),
porRangoSalarial: contarPorNoVacios( porRangoSalarial: contarPorNoVacios(
datos.oportunidades oportunidadesRango
.map((o) => bucketSalario(o.rangoSalarial ?? '')) .map((o) => bucketSalario(o.rangoSalarial ?? ''))
.filter((b): b is string => b !== null), .filter((b): b is string => b !== null),
), ),
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')), porMedioContacto: contarPorNoVacios(oportunidadesRango.map((o) => o.medioContacto ?? '')),
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')), porLoQuierePara: contarPorNoVacios(oportunidadesRango.map((o) => o.loQuierePara ?? '')),
porEdad: contarPorNoVacios( porEdad: contarPorNoVacios(
datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)), oportunidadesRango.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
), ),
}, },
}, },
@@ -391,7 +406,8 @@ export class ReportesService {
private readonly cacheMs: number private readonly cacheMs: number
private readonly salud: Pick<SaludMondayService, 'estaDisponible'> private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
private readonly cargarDatosLocales: CargadorDatosLocales private readonly cargarDatosLocales: CargadorDatosLocales
private cache: { resumen: ResumenReportes; expira: number } | null = null /** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */
private cache: Map<string, { resumen: ResumenReportes; expira: number }> = new Map()
constructor( constructor(
private readonly monday: MondayReportes, private readonly monday: MondayReportes,
@@ -406,15 +422,18 @@ export class ReportesService {
/** Invalida el cache (útil tras escrituras que cambian los números) */ /** Invalida el cache (útil tras escrituras que cambian los números) */
invalidarCache(): void { invalidarCache(): void {
this.cache = null this.cache.clear()
} }
async resumen(): Promise<ResumenReportes> { async resumen(dias?: number): Promise<ResumenReportes> {
const rango = dias !== undefined && Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined
const clave = String(rango ?? 0)
const ahoraMs = this.now() const ahoraMs = this.now()
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen const hit = this.cache.get(clave)
if (hit && hit.expira > ahoraMs) return hit.resumen
const resumen = await this.cargarResumen(ahoraMs) const resumen = await this.cargarResumen(ahoraMs, rango)
this.cache = { resumen, expira: ahoraMs + this.cacheMs } this.cache.set(clave, { resumen, expira: ahoraMs + this.cacheMs })
return resumen return resumen
} }
@@ -423,7 +442,7 @@ export class ReportesService {
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError * de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
* 502 a media lectura → resumen desde tablas locales con `parcial: true`. * 502 a media lectura → resumen desde tablas locales con `parcial: true`.
*/ */
private async cargarResumen(ahoraMs: number): Promise<ResumenReportes> { private async cargarResumen(ahoraMs: number, dias?: number): Promise<ResumenReportes> {
if (this.salud.estaDisponible()) { if (this.salud.estaDisponible()) {
try { try {
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado // Las fuentes de Monday se piden en paralelo; Prisma va por su lado
@@ -434,7 +453,7 @@ export class ReportesService {
this.monday.getActividadesAbiertas(), this.monday.getActividadesAbiertas(),
this.cargarPlanes(), this.cargarPlanes(),
]) ])
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs)) return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs), dias)
} catch (err) { } catch (err) {
if (!(err instanceof AppError && err.status === 502)) throw err if (!(err instanceof AppError && err.status === 502)) throw err
// Monday se cayó a media lectura: cae al camino local // Monday se cayó a media lectura: cae al camino local
@@ -444,6 +463,7 @@ export class ReportesService {
const resumen = construirResumen( const resumen = construirResumen(
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 }, { ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
new Date(ahoraMs), new Date(ahoraMs),
dias,
) )
// En modo local, los datos son completos (no parciales): todas las métricas // En modo local, los datos son completos (no parciales): todas las métricas
// vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó // vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó

View File

@@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from 'react'
import Burbuja from '../components/Burbuja' import Burbuja from '../components/Burbuja'
import DarkModeToggle from '../components/DarkModeToggle' import DarkModeToggle from '../components/DarkModeToggle'
import toast from "react-hot-toast" import toast from "react-hot-toast"
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api' import {
enviarMensaje,
esErrorDeRed,
listarConversaciones,
obtenerHistorial,
type ConversacionResumen,
} from '../services/api'
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion' import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
import type { Mensaje } from '../types' import type { Mensaje } from '../types'
@@ -23,6 +29,12 @@ export default function ChatPage() {
const [escribiendo, setEscribiendo] = useState(false) const [escribiendo, setEscribiendo] = useState(false)
const [sinConexion, setSinConexion] = useState(false) const [sinConexion, setSinConexion] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Texto del último envío fallido (habilita "Reintentar" junto al input)
const [pendiente, setPendiente] = useState<string | null>(null)
// Panel de conversaciones previas
const [panelAbierto, setPanelAbierto] = useState(false)
const [conversaciones, setConversaciones] = useState<ConversacionResumen[] | null>(null)
const [cargandoConversaciones, setCargandoConversaciones] = useState(false)
const finalRef = useRef<HTMLDivElement>(null) const finalRef = useRef<HTMLDivElement>(null)
// Al cargar: si hay sesión en localStorage, recupera su historial del API // Al cargar: si hay sesión en localStorage, recupera su historial del API
@@ -41,18 +53,15 @@ export default function ChatPage() {
finalRef.current?.scrollIntoView({ behavior: 'smooth' }) finalRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [mensajes, escribiendo]) }, [mensajes, escribiendo])
async function enviar() { /** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */
const texto = entrada.trim() async function transmitir(texto: string) {
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
setEscribiendo(true) setEscribiendo(true)
try { try {
const r = await enviarMensaje(sessionId, texto) const r = await enviarMensaje(sessionId, texto)
setSessionId(r.sessionId) setSessionId(r.sessionId)
guardarSesion(r.sessionId) guardarSesion(r.sessionId)
setSinConexion(false) setSinConexion(false)
setPendiente(null)
setMensajes((prev) => [ setMensajes((prev) => [
...prev, ...prev,
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes }, { id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
@@ -63,18 +72,63 @@ export default function ChatPage() {
} else { } else {
toast.error('Error al enviar el mensaje'); 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 setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar
} finally { } finally {
setEscribiendo(false) setEscribiendo(false)
} }
} }
async function enviar() {
const texto = entrada.trim()
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
await transmitir(texto)
}
/** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */
async function reintentar() {
if (!pendiente || escribiendo) return
setError(null)
setSinConexion(false)
await transmitir(pendiente)
}
function nuevaConversacion() { function nuevaConversacion() {
limpiarSesion() limpiarSesion()
setSessionId(null) setSessionId(null)
setMensajes([]) setMensajes([])
setError(null) setError(null)
setSinConexion(false) setSinConexion(false)
setPendiente(null)
}
/** Abre el panel y carga (una vez) el listado de conversaciones del usuario */
function abrirPanel() {
setPanelAbierto(true)
if (conversaciones !== null || cargandoConversaciones) return
setCargandoConversaciones(true)
listarConversaciones()
.then(setConversaciones)
.catch(() => setConversaciones([]))
.finally(() => setCargandoConversaciones(false))
}
/** Carga el historial de una conversación previa y la vuelve la activa */
async function abrirConversacion(id: string) {
try {
const historial = await obtenerHistorial(id)
setSessionId(id)
guardarSesion(id)
setMensajes(historial)
setPendiente(null)
setError(null)
setSinConexion(false)
setPanelAbierto(false)
} catch {
toast.error('No se pudo cargar la conversación')
}
} }
return ( return (
@@ -96,6 +150,14 @@ export default function ChatPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DarkModeToggle /> <DarkModeToggle />
<button
type="button"
onClick={abrirPanel}
aria-expanded={panelAbierto}
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]"
>
Historial
</button>
<button <button
type="button" type="button"
onClick={nuevaConversacion} onClick={nuevaConversacion}
@@ -163,6 +225,20 @@ export default function ChatPage() {
</p> </p>
</div> </div>
)} )}
{pendiente && !escribiendo && (
<div className="flex items-center justify-between gap-3 py-2">
<p className="text-xs font-medium text-red-600">
No se pudo enviar tu último mensaje.
</p>
<button
type="button"
onClick={() => void reintentar()}
className="rounded-lg bg-accent px-4 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover"
>
Reintentar
</button>
</div>
)}
<form <form
className="flex items-stretch gap-3 py-4" className="flex items-stretch gap-3 py-4"
onSubmit={(e) => { onSubmit={(e) => {
@@ -192,6 +268,64 @@ export default function ChatPage() {
</form> </form>
</div> </div>
</footer> </footer>
{/* Panel lateral: conversaciones previas del usuario */}
{panelAbierto && (
<div className="fixed inset-0 z-30 flex justify-end bg-black/40" onClick={() => setPanelAbierto(false)}>
<aside
className="h-full w-80 max-w-[85vw] overflow-y-auto border-l border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B]"
role="dialog"
aria-label="Conversaciones previas"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Conversaciones previas</h2>
<button
type="button"
onClick={() => setPanelAbierto(false)}
aria-label="Cerrar historial"
className="rounded-lg px-2 py-1 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
>
✕
</button>
</div>
{cargandoConversaciones ? (
<p className="mt-6 text-sm text-[#94A3B8]">Cargando…</p>
) : !conversaciones || conversaciones.length === 0 ? (
<p className="mt-6 text-sm text-[#94A3B8]">Sin conversaciones previas.</p>
) : (
<ul className="mt-4 space-y-2">
{conversaciones.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => void abrirConversacion(c.id)}
className={`w-full rounded-lg border p-3 text-left transition-colors ${
c.id === sessionId
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
}`}
>
<p className="truncate text-xs text-[#111827] dark:text-[#E2E8F0]">
{c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
</p>
<p className="mt-1 text-[10px] text-[#94A3B8]">
{new Date(c.createdAt).toLocaleString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</button>
</li>
))}
</ul>
)}
</aside>
</div>
)}
</div> </div>
) )
} }

View File

@@ -73,3 +73,14 @@ export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
fuentes: m.fuentes ?? [], fuentes: m.fuentes ?? [],
})) }))
} }
export interface ConversacionResumen {
id: string
createdAt: string
ultimoMensaje: { rol: string; contenido: string } | null
}
/** GET /ai/conversations — conversaciones previas del usuario (más reciente primero) */
export function listarConversaciones(): Promise<ConversacionResumen[]> {
return request<ConversacionResumen[]>('/ai/conversations')
}

View File

@@ -9,6 +9,7 @@ import type {
TipoCredito, TipoCredito,
SlotDocumento, SlotDocumento,
} from '../types' } from '../types'
import { getSesion } from '../services/auth'
export const PROYECTOS = [ export const PROYECTOS = [
'RESIDENCIAL MONTEBELLO', 'RESIDENCIAL MONTEBELLO',
@@ -102,5 +103,9 @@ export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOL
// El modelo de interés sale de MODELOS_POR_PROYECTO según el desarrollo elegido. // El modelo de interés sale de MODELOS_POR_PROYECTO según el desarrollo elegido.
export const OPCIONES_CALIFICA = ['Sí', 'No', 'Por confirmar'] as const export const OPCIONES_CALIFICA = ['Sí', 'No', 'Por confirmar'] as const
export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ' /**
* Nombre del asesor para el campo de firma del alta: se toma de la sesión
* activa en el momento en que arranca el alta. Sin sesión queda vacío.
*/
export const asesorDeSesion = (): string => getSesion()?.usuario.nombre ?? ''
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL' export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'

View File

@@ -1,5 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import { useForm, FormProvider } from 'react-hook-form' import { useForm, FormProvider, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import type { Referencia } from '../types' import type { Referencia } from '../types'
import { referenciasSchema } from '../lib/schemas' import { referenciasSchema } from '../lib/schemas'
@@ -11,13 +11,18 @@ import type { PasoProps } from './DatosGeneralesForm'
// RHF trabaja con objetos raíz; el array va envuelto // RHF trabaja con objetos raíz; el array va envuelto
const schemaFormulario = z.object({ referencias: referenciasSchema }) const schemaFormulario = z.object({ referencias: referenciasSchema })
/** Paso 5: referencias personales (4 filas, mínimo 1 con nombre y teléfono) */ const MAX_REFERENCIAS = 4
const referenciaVacia = (): Referencia => ({ nombre: '', telefono: '', parentesco: '', direccion: '' })
/** Paso 5: referencias personales (1 a 4 bloques; al menos 1 con nombre y teléfono) */
export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: PasoProps<Referencia[]>) { export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: PasoProps<Referencia[]>) {
const metodos = useForm<{ referencias: Referencia[] }>({ const metodos = useForm<{ referencias: Referencia[] }>({
resolver: zodResolver(schemaFormulario), resolver: zodResolver(schemaFormulario),
defaultValues: { referencias: valores }, defaultValues: { referencias: valores },
mode: 'onSubmit', mode: 'onSubmit',
}) })
const { fields, append, remove } = useFieldArray({ control: metodos.control, name: 'referencias' })
const errorRaiz = metodos.formState.errors.referencias const errorRaiz = metodos.formState.errors.referencias
return ( return (
@@ -30,9 +35,24 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
titulo="Referencias personales" titulo="Referencias personales"
descripcion="Captura al menos 1 referencia con nombre y teléfono." descripcion="Captura al menos 1 referencia con nombre y teléfono."
> >
{[0, 1, 2, 3].map((i) => ( {fields.map((field, i) => (
<div key={i} className="rounded-xl border border-[#E5E7EB] p-6"> <div key={field.id} className="rounded-xl border border-[#E5E7EB] p-6">
<p className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Referencia {i + 1}</p> <div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
Referencia {i + 1}
</p>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(i)}
aria-label={`Eliminar referencia ${i + 1}`}
title="Eliminar referencia"
className="flex h-7 w-7 items-center justify-center rounded-full border border-red-200 text-sm leading-none text-red-600 transition-colors duration-100 hover:bg-red-50"
>
×
</button>
)}
</div>
<div className="space-y-3"> <div className="space-y-3">
<CampoTexto nombre={`referencias.${i}.nombre`} etiqueta="Nombre completo" /> <CampoTexto nombre={`referencias.${i}.nombre`} etiqueta="Nombre completo" />
<CampoTexto nombre={`referencias.${i}.telefono`} etiqueta="Teléfono" /> <CampoTexto nombre={`referencias.${i}.telefono`} etiqueta="Teléfono" />
@@ -41,6 +61,17 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
</div> </div>
</div> </div>
))} ))}
<div className="md:col-span-2 lg:col-span-3">
<button
type="button"
onClick={() => append(referenciaVacia())}
disabled={fields.length >= MAX_REFERENCIAS}
title={fields.length >= MAX_REFERENCIAS ? `Máximo ${MAX_REFERENCIAS} referencias` : undefined}
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent transition-colors duration-100 hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
+ Agregar referencia
</button>
</div>
</SectionCard> </SectionCard>
{errorRaiz && typeof errorRaiz.message === 'string' && ( {errorRaiz && typeof errorRaiz.message === 'string' && (
<p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p> <p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import type { AltaCliente } from '../types' import type { AltaCliente } from '../types'
import { fechaISOHoy } from '../lib/formatters' import { fechaISOHoy } from '../lib/formatters'
import { ASESOR_DEFAULT, AUTORIDAD_ID_DEFAULT } from '../data/constantes' import { asesorDeSesion, AUTORIDAD_ID_DEFAULT } from '../data/constantes'
import { useDocumentos } from './useDocumentos' import { useDocumentos } from './useDocumentos'
const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({ const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({
@@ -56,10 +56,18 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
dependientes: { personas: '', parentesco: '', edad: '' }, dependientes: { personas: '', parentesco: '', edad: '' },
}) })
const referenciaVacia = (): AltaCliente['referencias'][number] => ({
nombre: '',
telefono: '',
parentesco: '',
direccion: '',
})
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
leadMondayId: null, leadMondayId: null,
apartadoMondayId: null, apartadoMondayId: null,
progresoId: null, progresoId: null,
foto: null,
datosGenerales: datosGeneralesVacios(), datosGenerales: datosGeneralesVacios(),
empleo: empleoVacio(), empleo: empleoVacio(),
economicos: economicosVacios(), economicos: economicosVacios(),
@@ -69,12 +77,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
empleo: empleoVacio(), empleo: empleoVacio(),
economicos: economicosVacios(), economicos: economicosVacios(),
}, },
referencias: [ referencias: [referenciaVacia()],
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
],
viviendaCredito: { viviendaCredito: {
proyecto: '', proyecto: '',
proyectoOtro: '', proyectoOtro: '',
@@ -107,7 +110,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
pais: 'MEXICO', pais: 'MEXICO',
}, },
}, },
firma: { firmaDataUrl: '', asesorNombre: ASESOR_DEFAULT }, firma: { firmaDataUrl: '', asesorNombre: asesorDeSesion() },
}) })
export const PASOS = [ export const PASOS = [

View File

@@ -12,6 +12,7 @@ export const fixtureAlta: AltaCliente = {
leadMondayId: null, leadMondayId: null,
apartadoMondayId: null, apartadoMondayId: null,
progresoId: null, progresoId: null,
foto: null,
datosGenerales: { datosGenerales: {
campaniaVenta: '', campaniaVenta: '',
fecha: '2026-07-19', fecha: '2026-07-19',

View File

@@ -170,4 +170,17 @@ describe('referenciasSchema', () => {
]) ])
expect(r.success).toBe(true) expect(r.success).toBe(true)
}) })
it('acepta un solo bloque de referencia (arreglo dinámico, ya no exige 4)', () => {
const r = referenciasSchema.safeParse([
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
])
expect(r.success).toBe(true)
})
it('rechaza más de 4 referencias', () => {
const valida = { nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: '', direccion: '' }
const r = referenciasSchema.safeParse([valida, valida, valida, valida, valida])
expect(r.success).toBe(false)
})
}) })

View File

@@ -149,7 +149,8 @@ const referenciaSchema = z.object({
export const referenciasSchema = z export const referenciasSchema = z
.array(referenciaSchema) .array(referenciaSchema)
.length(4, 'Se capturan 4 referencias') .min(1, 'Captura al menos 1 referencia con nombre y teléfono')
.max(4, 'Máximo 4 referencias')
.refine( .refine(
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''), (refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
'Captura al menos 1 referencia con nombre y teléfono', 'Captura al menos 1 referencia con nombre y teléfono',

View File

@@ -146,6 +146,11 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
), ),
).toHaveLength(0) ).toHaveLength(0)
// El flujo posterior queda idéntico: alta registrada con el id del progreso // El flujo posterior queda idéntico: alta registrada con el id del progreso
expect(await screen.findByText(/Alta registrada en el servidor: prog-1/)).toBeTruthy() expect(await screen.findByText(/Alta registrada correctamente/)).toBeTruthy()
expect(screen.getByText(/Folio prog-1/)).toBeTruthy()
// Pantalla de éxito con las acciones post-registro (sin redirect automático)
expect(screen.getByRole('button', { name: 'Generar liga de firma' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Ir a Mi día' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Registrar otro cliente' })).toBeTruthy()
}) })
}) })

View File

@@ -226,9 +226,9 @@ export default function AltaClientePage() {
setPantalla('wizard') setPantalla('wizard')
} }
const guardar = async () => { const guardar = async (pasoDestino: number = paso) => {
try { try {
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos }) const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos })
toast.success("Borrador guardado") toast.success("Borrador guardado")
setBorradorId(b.id) setBorradorId(b.id)
setAvisoBorrador( setAvisoBorrador(
@@ -239,6 +239,49 @@ export default function AltaClientePage() {
} }
} }
// ---------- Guard de cambios sin guardar (saltos con el Stepper) ----------
// El formulario del paso guarda sus capturas solo al avanzar con "Siguiente";
// si el usuario salta a otro paso con el Stepper, se le ofrece guardar antes.
const [sucio, setSucio] = useState(false)
// Paso elegido en el Stepper mientras el confirm de cambios sin guardar está abierto
const [destinoPendiente, setDestinoPendiente] = useState<number | null>(null)
// Al cambiar de paso el formulario se desmonta y remonta: se parte de cero
useEffect(() => {
setSucio(false)
}, [paso])
/** Cualquier edición dentro del paso actual marca cambios sin guardar */
const marcarSucio = () => setSucio(true)
/** Cambio de paso vía Stepper: confirma antes de saltar si hay cambios sin guardar */
const irAPasoConGuard = (destino: number) => {
if (destino === paso) return
if (sucio) {
setDestinoPendiente(destino)
return
}
irAPaso(destino)
}
/** "Guardar": persiste el borrador apuntando al paso destino y continúa hacia él */
const guardarYContinuar = async () => {
const destino = destinoPendiente
if (destino === null) return
setDestinoPendiente(null)
await guardar(destino)
irAPaso(destino)
}
/** "Continuar sin guardar": descarta lo capturado en el paso y salta al destino */
const continuarSinGuardar = () => {
const destino = destinoPendiente
if (destino === null) return
setDestinoPendiente(null)
setSucio(false)
irAPaso(destino)
}
// ---------- Guardado progresivo (borrador local + Contacto en Monday) ---------- // ---------- Guardado progresivo (borrador local + Contacto en Monday) ----------
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente // Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
// avance manda el estado completo (el PATCH es idempotente). // avance manda el estado completo (el PATCH es idempotente).
@@ -309,14 +352,20 @@ export default function AltaClientePage() {
} }
} }
// Redirigir a Mi día después de completar el alta // Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
useEffect(() => { const registrarOtro = () => {
if (!registro) return setRegistro('')
const timer = setTimeout(() => { setSinConexion(false)
window.location.href = '/hoy/' setErrorValidacion('')
}, 2000) setLigaUrl('')
return () => clearTimeout(timer) setLigaCopiada(false)
}, [registro]) setErrorLiga('')
empezarNuevo()
}
const irAMiDia = () => {
window.location.href = '/hoy/'
}
// Genera la liga pública y copia al portapapeles el texto listo para WhatsApp // Genera la liga pública y copia al portapapeles el texto listo para WhatsApp
const generarLiga = async () => { const generarLiga = async () => {
@@ -485,6 +534,77 @@ export default function AltaClientePage() {
) )
} }
// ---------- Pantalla de éxito: el alta quedó registrada en el servidor ----------
if (registro) {
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<AppHeader titulo="Alta de Clientes" />
<main className="px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
<div className="mx-auto max-w-2xl">
<div className="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-900 dark:bg-green-950/40">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-green-100 text-lg text-green-700 dark:bg-green-900 dark:text-green-400"
>
✓
</span>
<div className="min-w-0">
<h1 className="text-lg font-semibold text-green-800 dark:text-green-300">
Alta registrada correctamente
</h1>
<p className="mt-0.5 truncate text-xs text-green-700 dark:text-green-400">
{alta.datosGenerales.nombreCompleto || 'Cliente'} · Folio {registro}
</p>
</div>
</div>
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={() => void generarLiga()}
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"
>
Generar liga de firma
</button>
<button
type="button"
onClick={irAMiDia}
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]"
>
Ir a Mi día
</button>
<button
type="button"
onClick={registrarOtro}
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]"
>
Registrar otro cliente
</button>
</div>
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
celular (vigencia 72 h, sin usuario ni contraseña).
</p>
{ligaUrl && (
<p className="mt-3 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-medium text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
</div>
</main>
</div>
)
}
return ( return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]"> <div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<AppHeader <AppHeader
@@ -515,10 +635,10 @@ export default function AltaClientePage() {
<main className="px-4 py-6 sm:px-6 sm:py-8 lg:px-8"> <main className="px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
<div className="md:grid md:grid-cols-[260px_minmax(0,1fr)] md:gap-8 lg:gap-8"> <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"> <div className="mb-6 md:sticky md:top-20 md:mb-0 md:self-start">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} /> <Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPasoConGuard} />
</div> </div>
<div className="w-full max-w-3xl"> <div className="w-full max-w-3xl" onInput={marcarSucio} onChange={marcarSucio}>
{errorCarga && ( {errorCarga && (
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"> <p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
{errorCarga} {errorCarga}
@@ -549,9 +669,10 @@ export default function AltaClientePage() {
</p> </p>
<div className="mt-3"> <div className="mt-3">
<FotoCliente <FotoCliente
foto={null} foto={datos.foto}
onFoto={() => { onFoto={(foto) => {
// Foto capturada (el componente gestiona el preview internamente) actualizar('foto', foto || null)
marcarSucio()
}} }}
/> />
</div> </div>
@@ -662,16 +783,6 @@ export default function AltaClientePage() {
)} )}
{paso === 7 && ( {paso === 7 && (
<> <>
{registro && (
<p className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
Alta registrada en el servidor: {registro}
</p>
)}
{registro && (
<p className="mb-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Redirigiendo a Mi día en unos segundos…
</p>
)}
{sinConexion && ( {sinConexion && (
<p <p
role="alert" role="alert"
@@ -688,35 +799,6 @@ export default function AltaClientePage() {
El servidor rechazó el alta: {errorValidacion} El servidor rechazó el alta: {errorValidacion}
</p> </p>
)} )}
{registro && (
<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] 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 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] dark:text-[#E2E8F0]">
{ligaUrl}
{ligaCopiada && (
<span role="status" className="ml-2 font-medium text-accent">
Liga y mensaje de WhatsApp copiados
</span>
)}
</p>
)}
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
</div>
)}
<TarjetaPaso> <TarjetaPaso>
<FirmasForm <FirmasForm
alta={alta} alta={alta}
@@ -729,6 +811,44 @@ export default function AltaClientePage() {
</div> </div>
</div> </div>
</main> </main>
{/* Confirmación de cambios sin guardar al saltar de paso con el Stepper */}
{destinoPendiente !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-900/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="guard-cambios-titulo"
>
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<h2
id="guard-cambios-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Cambios sin guardar
</h2>
<p className="mt-2 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
¿Deseas guardar antes de continuar?
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={continuarSinGuardar}
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]"
>
Continuar sin guardar
</button>
<button
type="button"
onClick={() => void guardarYContinuar()}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
Guardar
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -157,6 +157,9 @@ export interface AltaCliente {
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/ // ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato. // iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
progresoId: string | null progresoId: string | null
// Foto del cliente (data URL base64) capturada en el paso de datos generales;
// null cuando aún no se toma. Viaja en el payload del alta y en los borradores.
foto: string | null
datosGenerales: DatosGenerales datosGenerales: DatosGenerales
empleo: DatosEmpleo empleo: DatosEmpleo
economicos: DatosEconomicos economicos: DatosEconomicos

View File

@@ -1,36 +1,36 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { request } from '../services/api' import { request } from '../services/api'
import { formatoMoneda } from '../lib/formatters' import { formatoMoneda } from '../lib/formatters'
import { telefonoParaEnlace } from '../lib/cobranza'
import KpiCard from './KpiCard' import KpiCard from './KpiCard'
/** Registro de cliente moroso del endpoint GET /api/reportes/mora */ /** Registro de cliente moroso del endpoint GET /api/reportes/mora (mora.service) */
export interface ClienteMora { export interface ClienteMora {
clienteId: string
clienteNombre: string clienteNombre: string
clienteTelefono: string
unidadCodigo: string unidadCodigo: string
proyecto: string proyecto: string
pagoVencido: number // ISO fecha pagoVencido: number // número de pago vencido
monto: number monto: number
diasAtraso: number diasAtraso: number
telefono: string | null
planId: string
} }
interface MoraResponse { interface MoraResponse {
totalMorosos: number clientesConMora: number
montoTotalVencido: number montoTotalVencido: number
diasPromedio: number diasPromedioAtraso: number
clientes: ClienteMoria[] clientes: ClienteMora[]
} }
/** Normaliza la respuesta del API; acepta tanto el shape del API como el local */
type ClienteMoria = ClienteMora
function abrirWhatsApp(telefono: string) { function abrirWhatsApp(telefono: string) {
const limpio = telefono.replace(/[^0-9+]/g, '') // Misma normalización +52 que mi-dia: 10 dígitos → México
window.open(`https://wa.me/${limpio.startsWith('+') ? limpio.slice(1) : limpio}`, '_blank') window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
} }
export default function MoraDashboard() { export default function MoraDashboard() {
const navigate = useNavigate()
const [datos, setDatos] = useState<MoraResponse | null>(null) const [datos, setDatos] = useState<MoraResponse | null>(null)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -80,9 +80,9 @@ export default function MoraDashboard() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<KpiCard titulo="Total morosos" valor={datos.totalMorosos} /> <KpiCard titulo="Total morosos" valor={datos.clientesConMora} />
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} /> <KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedio} /> <KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
</div> </div>
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]"> <div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -100,7 +100,7 @@ export default function MoraDashboard() {
</thead> </thead>
<tbody className="divide-y divide-[#E5E7EB]"> <tbody className="divide-y divide-[#E5E7EB]">
{datos.clientes.map((c) => ( {datos.clientes.map((c) => (
<tr key={c.clienteId} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]"> <tr key={`${c.planId}-${c.pagoVencido}`} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td> <td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td> <td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td> <td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
@@ -114,15 +114,25 @@ export default function MoraDashboard() {
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
{c.clienteTelefono && ( <div className="flex items-center justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => abrirWhatsApp(c.clienteTelefono)} onClick={() => navigate(`/planes/${c.planId}`)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700" className="text-xs font-medium text-accent hover:underline"
aria-label={`Ver plan de ${c.clienteNombre}`}
> >
WhatsApp Ver plan →
</button> </button>
)} {c.telefono && (
<button
type="button"
onClick={() => c.telefono && abrirWhatsApp(c.telefono)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
>
WhatsApp
</button>
)}
</div>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -183,6 +183,16 @@ export interface CobranzaItem {
atraso: number atraso: number
} }
/**
* Normaliza un teléfono a dígitos para enlaces wa.me. Si el número no trae
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
*/
export function telefonoParaEnlace(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 10) return `52${digitos}`
return digitos
}
/** /**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS: * Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos * pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos

View File

@@ -370,6 +370,7 @@ export default function DashboardPage() {
<thead> <thead>
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"> <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">Cliente</th>
<th scope="col" className="px-4 py-3">Teléfono</th>
<th scope="col" className="px-4 py-3">Unidad</th> <th scope="col" className="px-4 py-3">Unidad</th>
<th scope="col" className="px-4 py-3">Proyecto</th> <th scope="col" className="px-4 py-3">Proyecto</th>
{encabezadoOrdenable('avance', '% pagado')} {encabezadoOrdenable('avance', '% pagado')}
@@ -387,6 +388,7 @@ export default function DashboardPage() {
{d.plan.clienteNombre} {d.plan.clienteNombre}
</Link> </Link>
</td> </td>
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{d.plan.clienteTelefono || '—'}</td>
<td className="px-4 py-3">{d.plan.unidadCodigo}</td> <td className="px-4 py-3">{d.plan.unidadCodigo}</td>
<td className="px-4 py-3">{d.plan.proyecto}</td> <td className="px-4 py-3">{d.plan.proyecto}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -414,7 +416,7 @@ export default function DashboardPage() {
))} ))}
{ordenados.length === 0 && ( {ordenados.length === 0 && (
<tr> <tr>
<td colSpan={8} className="py-8 text-center text-neutral-500"> <td colSpan={9} className="py-8 text-center text-neutral-500">
No hay planes que coincidan con el filtro. No hay planes que coincidan con el filtro.
</td> </td>
</tr> </tr>

View File

@@ -12,6 +12,7 @@ import {
diasAtraso, diasAtraso,
moraDePago, moraDePago,
pagadoTotal, pagadoTotal,
proximoVencimiento,
round2, round2,
saldoPlan, saldoPlan,
statusPago, statusPago,
@@ -136,22 +137,43 @@ export default function PlanDetallePage() {
const avance = avancePlan(plan) const avance = avancePlan(plan)
/** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */
const proximoPago = status === 'ACTIVO' ? proximoVencimiento(plan, hoy) : null
/** Abonar al próximo pago: hace scroll a su fila y abre el modal prellenado */
const abonarProximoPago = (pago: Pago) => {
document.getElementById(`pago-${pago.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
setPagoAbono(pago)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="flex flex-wrap items-start justify-between gap-4">
<Link to="/" className="text-sm text-accent hover:underline"> <div>
← Volver al dashboard <Link to="/" className="text-sm text-accent hover:underline">
</Link> ← Volver al dashboard
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1> </Link>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]"> <h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '} <p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span> {plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>} <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
</p> {atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]"> </p>
Tel. {plan.clienteTelefono || '—'} <p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>} Tel. {plan.clienteTelefono || '—'}
</p> {tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
</p>
</div>
{proximoPago && (
<button
type="button"
onClick={() => abonarProximoPago(proximoPago)}
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"
aria-label="Registrar abono al próximo pago no cubierto"
>
Abonar al próximo pago
</button>
)}
</div> </div>
{offline && ( {offline && (
@@ -245,7 +267,7 @@ export default function PlanDetallePage() {
const expandido = expandidos.has(pago.id) const expandido = expandidos.has(pago.id)
return ( return (
<Fragment key={pago.id}> <Fragment key={pago.id}>
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]"> <tr id={`pago-${pago.id}`} 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 font-medium">{pago.numPago}</td>
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td> <td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td> <td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>

View File

@@ -13,6 +13,9 @@ import {
type StorageLike, type StorageLike,
} from './repository' } from './repository'
import { ErrorApi } from './api' import { ErrorApi } from './api'
// Misma resolución de base que api.ts: el test no depende de la VITE_API_URL local
const API_URL = import.meta.env.VITE_API_URL || '/api'
import type { PlanPago } from '../types' import type { PlanPago } from '../types'
/** Stub sencillo de localStorage para correr en entorno node */ /** Stub sencillo de localStorage para correr en entorno node */
@@ -120,7 +123,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(plan.tasaMoraMensual).toBeUndefined() expect(plan.tasaMoraMensual).toBeUndefined()
expect(plan).not.toHaveProperty('saldo') expect(plan).not.toHaveProperty('saldo')
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes', `${API_URL}/planes`,
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
) )
}) })
@@ -205,7 +208,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.plan.id).toBe('api-nuevo') expect(r.plan.id).toBe('api-nuevo')
expect(storage.getItem(CLAVE_STORAGE)).toBeNull() expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
const [url, init] = fetchMock.mock.calls[0] const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes') expect(url).toBe(`${API_URL}/planes`)
expect(init?.method).toBe('POST') expect(init?.method).toBe('POST')
const body = JSON.parse(String(init?.body)) as Record<string, unknown> const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body.clienteNombre).toBe('Cliente Prueba') expect(body.clienteNombre).toBe('Cliente Prueba')
@@ -239,7 +242,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.origen).toBe('api') expect(r.origen).toBe('api')
const [url, init] = fetchMock.mock.calls[0] const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/abonos') expect(url).toBe(`${API_URL}/planes/api-1/abonos`)
const body = JSON.parse(String(init?.body)) as Record<string, unknown> const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' }) expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' })
}) })
@@ -291,7 +294,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
texto: 'Se habló con el cliente', texto: 'Se habló con el cliente',
}) })
const [url] = fetchMock.mock.calls[0] const [url] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/notas') expect(url).toBe(`${API_URL}/planes/api-1/notas`)
}) })
it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => { it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => {
@@ -317,7 +320,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
expect(r.origen).toBe('api') expect(r.origen).toBe('api')
expect(r.plan.status).toBe('CANCELADO') expect(r.plan.status).toBe('CANCELADO')
const [url, init] = fetchMock.mock.calls[0] const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/cancelar') expect(url).toBe(`${API_URL}/planes/api-1/cancelar`)
expect(init?.method).toBe('POST') expect(init?.method).toBe('POST')
}) })
@@ -361,7 +364,7 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
expect(r.origen).toBe('api') expect(r.origen).toBe('api')
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'http://192.168.10.123:3000/api/planes?mios=1', `${API_URL}/planes?mios=1`,
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }), expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
) )
}) })

View File

@@ -107,12 +107,15 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
> >
Retomar Retomar
</button> </button>
<a {/* El alta prellenada requiere la cotización en el servidor; las locales (CTE-LOCAL-*) no la tienen */}
href={`/client/?cotizacion=${c.id}`} {!c.id.startsWith('local-') && (
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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]" <a
> href={`/client/?cotizacion=${c.id}`}
Iniciar alta 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] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
</a> >
Iniciar alta
</a>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -55,6 +55,30 @@ describe('crearDocumentoPdf', () => {
}) })
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1) expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
}) })
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
precioLista: unidad.precioConAcabados,
propuestas,
cliente: { nombre: 'Juan Pérez' },
folio: 'CTE-LOCAL-1757568000000',
})
// El folio local viaja en el contenido del PDF aunque no se haya guardado en el API
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
expect(pdf).toContain('CTE-LOCAL-1757568000000')
expect(pdf).toContain('FOLIO')
})
}) })
describe('cuota de mantenimiento', () => { describe('cuota de mantenimiento', () => {

View File

@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import CotizadorPage from './CotizadorPage'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Smoke del cierre de la cotización:
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF
* - preselección por defecto de la propuesta de menor total
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
*/
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
const respuesta = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
// El entorno de test no trae localStorage: mock en memoria para el fallback local
const almacenamiento = new Map<string, string>()
beforeEach(() => {
almacenamiento.clear()
vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
removeItem: (k: string) => void almacenamiento.delete(k),
clear: () => almacenamiento.clear(),
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('CotizadorPage — barra sticky y guardado local', () => {
it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const metodo = init?.method ?? 'GET'
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([]))
if (url.includes('/cotizaciones') && metodo === 'POST') {
return Promise.reject(new TypeError('servidor caído'))
}
return Promise.reject(new TypeError(`fetch inesperado: ${metodo} ${url}`))
})
vi.stubGlobal('fetch', fetchMock)
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
// Barra sticky siempre presente, con las tres acciones
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy()
// Elegir una unidad del inventario
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
fireEvent.focus(inputUnidad)
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
// Preselección por defecto: la propuesta de menor total pagado
const selectPropuesta = (await screen.findByLabelText(
'Cambiar propuesta elegida',
)) as HTMLSelectElement
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy()
// Guardar con el servidor caído → folio local persistido en el dispositivo
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
target: { value: 'Cliente Prueba Local' },
})
fireEvent.click(screen.getByRole('button', { name: 'Guardar' }))
await waitFor(() => {
const locales = JSON.parse(
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
) as { folio?: string; clienteNombre?: string }[]
expect(locales).toHaveLength(1)
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
})
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
})
})

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@@ -21,6 +21,7 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia' import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio' import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir' import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos' import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import { import {
getMisCotizaciones, getMisCotizaciones,
@@ -30,6 +31,7 @@ import {
TIPO_DE_API, TIPO_DE_API,
type CotizacionGuardada, type CotizacionGuardada,
} from '../services/api' } from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
import type { Cliente } from '../types' import type { Cliente } from '../types'
const esquemaCliente = z.object({ const esquemaCliente = z.object({
@@ -87,6 +89,8 @@ export default function CotizadorPage() {
const [errorMias, setErrorMias] = useState<string | null>(null) const [errorMias, setErrorMias] = useState<string | null>(null)
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null) const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
const [modalClienteAbierto, setModalClienteAbierto] = useState(false) const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
// true cuando el modal de cliente se abrió desde "Guardar" (sin generar PDF)
const [guardarSinPdf, setGuardarSinPdf] = useState(false)
const [guardando, setGuardando] = useState(false) const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState<string | null>(null) const [aviso, setAviso] = useState<string | null>(null)
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda // Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
@@ -96,9 +100,22 @@ export default function CotizadorPage() {
useEffect(() => { useEffect(() => {
let vivo = true let vivo = true
/** Combina servidor + locales, sin duplicados, más recientes primero */
const mezclar = (principal: CotizacionGuardada[], extra: CotizacionGuardada[]) => {
const ids = new Set(principal.map((c) => c.id))
const combinadas = [...principal, ...extra.filter((c) => !ids.has(c.id))]
return combinadas
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
.slice(0, 50)
}
// Cotizaciones guardadas localmente cuando el API no respondió
void cargarCotizacionesLocales().then((locales) => {
if (!vivo || locales.length === 0) return
setMisCotizaciones((prev) => mezclar(prev, locales as CotizacionGuardada[]))
})
getMisCotizaciones() getMisCotizaciones()
.then((lista) => { .then((lista) => {
if (vivo) setMisCotizaciones(lista) if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev))
}) })
.catch((err) => { .catch((err) => {
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err) console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
@@ -162,9 +179,63 @@ export default function CotizadorPage() {
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo // La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */ // ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
// más barata; la elección manual del vendedor se respeta hasta el siguiente
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
const unidadPrevia = useRef<string | null | undefined>(undefined)
const saltarAuto = useRef(false)
useEffect(() => {
const codigo = unidad?.codigo ?? null
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
unidadPrevia.current = codigo
if (!cambioUnidad || saltarAuto.current) {
saltarAuto.current = false
return
}
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
}, [unidad?.codigo, propuestas, apartadoEfectivo])
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
const timestamp = Date.now()
return {
id: `local-${timestamp}`,
folio: `CTE-LOCAL-${timestamp}`,
unidadCodigo: unidad!.codigo,
desarrollo,
clienteNombre: cliente.nombre,
clienteTelefono: cliente.telefono ?? null,
clienteEmail: cliente.email ?? null,
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
precioLista,
precioFinal: propuestaActiva.precioFinal,
enganche: propuestaActiva.enganche,
numMensualidades: propuestaActiva.numMensualidades ?? 0,
mensualidad: propuestaActiva.mensualidad,
pagoFinal: propuestaActiva.pagoFinal,
createdAt: new Date(timestamp).toISOString(),
}
}
/**
* Persiste la cotización en el API. Si no responde, la guarda localmente
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
*/
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => { const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null if (!unidad || !propuestaActiva) return null
try { try {
@@ -186,9 +257,14 @@ export default function CotizadorPage() {
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50)) setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio return conFolio
} catch (err) { } catch (err) {
toast.error('No se pudo guardar la cotización'); console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err) console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).') const local = cotizacionLocal(cliente)
return null void guardarCotizacionLocal(local)
setUltimaGuardada(local)
setMisCotizaciones((prev) => [local, ...prev].slice(0, 50))
toast('Cotización guardada en este dispositivo (sin conexión al servidor)')
setAviso(`Sin conexión al servidor: cotización guardada localmente con folio ${local.folio}.`)
return local
} }
} }
@@ -201,10 +277,13 @@ export default function CotizadorPage() {
let qrDataUrl: string | undefined let qrDataUrl: string | undefined
if (guardada) { if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id) folio = guardada.folio ?? folioCotizacion(guardada.id)
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), { // El QR apunta al resumen público en el servidor; una cotización local no lo tiene
margin: 1, if (!esCotizacionLocal(guardada)) {
width: 200, qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
}) margin: 1,
width: 200,
})
}
} }
generarPdf({ generarPdf({
unidad, unidad,
@@ -227,6 +306,7 @@ export default function CotizadorPage() {
if (!unidad) return if (!unidad) return
// Sin nombre capturado, pide los datos del cliente en el modal // Sin nombre capturado, pide los datos del cliente en el modal
if (nombreCliente.trim().length < 3) { if (nombreCliente.trim().length < 3) {
setGuardarSinPdf(false)
setModalClienteAbierto(true) setModalClienteAbierto(true)
return return
} }
@@ -237,12 +317,42 @@ export default function CotizadorPage() {
}) })
} }
/** Guarda solo (sin PDF); con el flujo de modal si falta el nombre del cliente */
const guardarSolo = async (cliente: Cliente) => {
if (!unidad) return
setGuardando(true)
try {
await guardarEnApi(cliente)
} finally {
setGuardando(false)
}
}
const handleGuardar = () => {
if (!unidad) return
if (nombreCliente.trim().length < 3) {
setGuardarSinPdf(true)
setModalClienteAbierto(true)
return
}
void guardarSolo({
nombre: nombreCliente.trim(),
email: watch('email') || undefined,
telefono: watch('telefono') || undefined,
})
}
const handleModalAceptar = (cliente: Cliente) => { const handleModalAceptar = (cliente: Cliente) => {
setModalClienteAbierto(false) setModalClienteAbierto(false)
setValue('nombre', cliente.nombre, { shouldValidate: true }) setValue('nombre', cliente.nombre, { shouldValidate: true })
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true }) if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true }) if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
void generarPdfConCliente(cliente) if (guardarSinPdf) {
setGuardarSinPdf(false)
void guardarSolo(cliente)
} else {
void generarPdfConCliente(cliente)
}
} }
const handleCompartir = async () => { const handleCompartir = async () => {
@@ -256,7 +366,10 @@ export default function CotizadorPage() {
...(ultimaGuardada ...(ultimaGuardada
? { ? {
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id), folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
urlPublica: urlPublicaCotizacion(ultimaGuardada.id), // La URL pública solo existe si la cotización vive en el servidor
...(esCotizacionLocal(ultimaGuardada)
? {}
: { urlPublica: urlPublicaCotizacion(ultimaGuardada.id) }),
} }
: {}), : {}),
}) })
@@ -281,6 +394,7 @@ export default function CotizadorPage() {
} }
const tipo = TIPO_DE_API[c.tipoPropuesta] const tipo = TIPO_DE_API[c.tipoPropuesta]
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo) const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
restaurar({ restaurar({
desarrollo: c.desarrollo, desarrollo: c.desarrollo,
unidad: u, unidad: u,
@@ -306,7 +420,7 @@ export default function CotizadorPage() {
return ( return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]"> <div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<main className="px-4 py-8 sm:px-6 lg:px-8 lg:py-10"> <main className="px-4 py-8 pb-28 sm:px-6 lg:px-8 lg:py-10 lg:pb-28">
{/* Encabezado de página */} {/* Encabezado de página */}
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
@@ -599,49 +713,6 @@ export default function CotizadorPage() {
propuestas={propuestas} propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)} titulos={propuestas.map((p) => p.titulo)}
/> />
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-end">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
</label>
<select
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
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:ring-2 focus:ring-accent/20 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
Compartir
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
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 disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{aviso && <p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</div>
</div> </div>
)} )}
</section> </section>
@@ -664,6 +735,71 @@ export default function CotizadorPage() {
</div> </div>
</main> </main>
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{propuestaActiva ? (
<>
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
<span className="truncate">
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
</span>
</span>
<select
aria-label="Cambiar propuesta elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</>
) : (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para elegir propuesta, guardar y generar el PDF.
</p>
)}
{aviso && (
<p role="status" className="w-full text-xs text-[#64748B] dark:text-[#94A3B8] sm:w-auto sm:max-w-xs sm:truncate">
{aviso}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
Compartir
</button>
<button
type="button"
onClick={handleGuardar}
disabled={!puedeGenerarPdf}
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] disabled:cursor-not-allowed disabled:opacity-40 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]"
>
Guardar
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{guardando ? 'Guardando…' : 'Generar PDF'}
</button>
</div>
</div>
</div>
<ModalCliente <ModalCliente
abierto={modalClienteAbierto} abierto={modalClienteAbierto}
nombreInicial={watch('nombre')} nombreInicial={watch('nombre')}

View File

@@ -12,22 +12,82 @@ async function getDB(): Promise<IDBPDatabase> {
}) })
} }
// Todas las operaciones son tolerantes a fallos: sin IndexedDB (navegación
// privada, tests) no lanzan, para no romper el flujo que las usa.
export async function guardarUnidades(unidades: unknown[]) { export async function guardarUnidades(unidades: unknown[]) {
const db = await getDB() try {
await db.put('unidades', unidades, 'actual') const db = await getDB()
await db.put('unidades', unidades, 'actual')
} catch {
// Sin almacenamiento: el inventario se recarga del API en la próxima sesión
}
} }
export async function cargarUnidadesLocal() { export async function cargarUnidadesLocal(): Promise<unknown> {
const db = await getDB() try {
return db.get('unidades', 'actual') const db = await getDB()
return db.get('unidades', 'actual')
} catch {
return undefined
}
} }
export async function guardarCotizaciones(cotizaciones: unknown[]) { export async function guardarCotizaciones(cotizaciones: unknown[]) {
const db = await getDB() try {
await db.put('cotizaciones', cotizaciones, 'historial') const db = await getDB()
await db.put('cotizaciones', cotizaciones, 'historial')
} catch {
// Sin almacenamiento
}
} }
export async function cargarCotizacionesLocal() { export async function cargarCotizacionesLocal(): Promise<unknown> {
const db = await getDB() try {
return db.get('cotizaciones', 'historial') const db = await getDB()
return db.get('cotizaciones', 'historial')
} catch {
return undefined
}
}
// ---------- Cotizaciones guardadas localmente (fallback sin servidor) ----------
const COTIZACIONES_LS = 'frasa-cotizador:cotizaciones-locales'
const MAX_LOCALES = 50
/**
* Guarda una cotización local (id con prefijo 'local-') en IndexedDB.
* Si IDB no está disponible (p. ej. navegación privada) cae a localStorage;
* si no hay almacenamiento alguno, no lanza: la sesión sigue.
*/
export async function guardarCotizacionLocal(cotizacion: unknown): Promise<void> {
try {
const db = await getDB()
await db.put('cotizaciones', cotizacion, `local:${(cotizacion as { id: string }).id}`)
return
} catch {
// IndexedDB no disponible: intenta localStorage
}
try {
const prev = JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
localStorage.setItem(COTIZACIONES_LS, JSON.stringify([cotizacion, ...prev].slice(0, MAX_LOCALES)))
} catch {
// Sin almacenamiento disponible
}
}
/** Lee las cotizaciones guardadas localmente (IndexedDB o localStorage) */
export async function cargarCotizacionesLocales(): Promise<unknown[]> {
try {
const db = await getDB()
const claves = (await db.getAllKeys('cotizaciones')).filter((k) => String(k).startsWith('local:'))
return Promise.all(claves.map((k) => db.get('cotizaciones', k)))
} catch {
// IndexedDB no disponible: intenta localStorage
}
try {
return JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
} catch {
return []
}
} }

View File

@@ -13,6 +13,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3",
"recharts": "^2.15.0" "recharts": "^2.15.0"
}, },
"devDependencies": { "devDependencies": {
@@ -2128,6 +2129,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cssstyle": { "node_modules/cssstyle": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -3271,6 +3285,44 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": { "node_modules/react-smooth": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@@ -3424,6 +3476,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",

View File

@@ -15,6 +15,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.18.3",
"recharts": "^2.15.0" "recharts": "^2.15.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,11 +1,32 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) { const CLASE_BASE =
return ( 'rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md'
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
interface Props {
titulo: string
valor: ReactNode
detalle?: string
/** Presente = KPI clickeable (drill-down): se renderiza como botón */
onClick?: () => void
}
export default function KpiCard({ titulo, valor, detalle, onClick }: Props) {
const contenido = (
<>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</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> <p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
{detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>} {detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>}
</div> </>
)
if (!onClick) return <div className={CLASE_BASE}>{contenido}</div>
return (
<button
type="button"
onClick={onClick}
className={`${CLASE_BASE} w-full cursor-pointer text-left transition-colors hover:border-accent`}
>
{contenido}
</button>
) )
} }

View File

@@ -3,26 +3,28 @@ import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } fr
import { tooltipProps } from '../lib/chart-theme' import { tooltipProps } from '../lib/chart-theme'
import KpiCard from './KpiCard' import KpiCard from './KpiCard'
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes' import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero' import { funnelMiTablero, type MiTablero } from '../lib/mi-tablero'
import { fetchResumenMiDia } from '../services/mi-dia' import { fetchMiTablero } from '../services/reportes'
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros) // Misma paleta del funnel del tablero general (NovaSpark: azules + neutros);
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB'] // rojo para la etapa final "Descartados"
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB', '#EF4444']
/** /**
* "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades, * "Mi tablero": KPIs personales del vendedor logueado y su funnel de conversión
* apartados por vencer) y un funnel simple de sus leads por estado. * (Leads → Oportunidades → Apartados → Vendidos → Descartados).
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API). * Fuente: GET /api/reportes/mi-tablero, el mismo endpoint que usa el panel
* "Mi día" (DashboardVendedor).
*/ */
export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) { export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) {
const [resumen, setResumen] = useState<ResumenMiDia | null>(null) const [tablero, setTablero] = useState<MiTablero | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
const cargar = useCallback(async () => { const cargar = useCallback(async () => {
setCargando(true) setCargando(true)
try { try {
setResumen(await fetchResumenMiDia()) setTablero(await fetchMiTablero())
setError(null) setError(null)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero') setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
@@ -40,15 +42,20 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
alCambiarAcciones({ actualizar: () => void cargar(), cargando }) alCambiarAcciones({ actualizar: () => void cargar(), cargando })
}, [alCambiarAcciones, cargar, cargando]) }, [alCambiarAcciones, cargar, cargando])
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : [] const funnel = tablero ? funnelMiTablero(tablero) : []
const conversion = tablero?.conversion
const tasaConversion =
conversion && conversion.leads > 0
? `${((conversion.oportunidades / conversion.leads) * 100).toFixed(1)}%`
: '—'
return ( return (
<main className="px-4 py-8 sm:px-6 lg:px-8"> <main className="px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1> <h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1>
{resumen && ( {tablero && (
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]"> <p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} Actualizado: {new Date(tablero.generadoEn).toLocaleString('es-MX')}
</p> </p>
)} )}
</div> </div>
@@ -59,39 +66,43 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
</p> </p>
)} )}
{resumen?.sinMapeoMonday && ( {!tablero && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
<p className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700"> {!tablero && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías
</p>
)}
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />} {tablero && conversion && (
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
{resumen && (
<> <>
<section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> <section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<KpiCard <KpiCard
titulo="Mis leads activos" titulo="Leads totales"
valor={resumen.misLeads.length} valor={conversion.leads}
detalle={`${resumen.conteos.leadsNuevosSemana} nuevo(s) esta semana`} detalle={`${conversion.oportunidades} oportunidades · ${conversion.vendidos} vendidos`}
/> />
<KpiCard <KpiCard
titulo="Mis actividades abiertas" titulo="Tasa de conversión"
valor={resumen.conteos.actividadesAbiertas} valor={tasaConversion}
detalle={`${resumen.misActividadesHoy.length} con fecha de hoy o antes`} detalle={`${conversion.oportunidades} de ${conversion.leads} leads`}
/> />
<KpiCard <KpiCard
titulo="Apartados por vencer" titulo="Ventas del mes"
valor={resumen.apartadosPorVencer.length} valor={conversion.vendidos}
detalle="próximos 7 días (pipeline completo)" detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN · ${tablero.ventasMes.unidades} cotizada(s)`}
/>
<KpiCard
titulo="Actividades abiertas"
valor={tablero.actividadesPendientes}
detalle={`${tablero.actividadesHoy} con fecha de hoy o antes`}
/>
<KpiCard
titulo="Leads descartados"
valor={conversion.descartados}
detalle="etapa terminal DESCARTADO"
/> />
</section> </section>
<section aria-label="Mis leads por estado" className="mt-10"> <section aria-label="Funnel de conversión" className="mt-10">
<TarjetaGrafica titulo="Mis leads por estado"> <TarjetaGrafica titulo="Funnel de conversión (incluye descartados)">
{funnel.length === 0 ? ( {funnel.every((e) => e.valor === 0) ? (
<EstadoVacio mensaje="Sin leads asignados" /> <EstadoVacio mensaje="Sin movimiento comercial" />
) : ( ) : (
<div className="h-72"> <div className="h-72">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -43,10 +44,28 @@ const COLORES_STATUS: Record<string, string> = {
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB'] const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
const COLORES_COBRANZA = ['#2563EB', '#F59E0B'] const COLORES_COBRANZA = ['#2563EB', '#F59E0B']
/** Rangos de fecha disponibles para filtrar las gráficas (días hacia atrás) */
const RANGOS_DIAS = [7, 30, 90] as const
/** Rutas de drill-down a otras apps del ecosistema (NGINX las sirve como SPAs aparte) */
const RUTA_PLANES_VENCIDOS = '/pay/?vista=todos&status=ACTIVO&sort=atraso&dir=desc'
const RUTA_MI_DIA = '/hoy/'
/**
* Drill-down cruzado de apps: /pay (cobranza) y /hoy (mi-dia) son SPAs
* distintas detrás de NGINX; navigate() de react-router no puede salir del
* basename de esta app, así que el salto es a nivel document.
*/
function irAApp(ruta: string): void {
window.location.assign(ruta)
}
/** /**
* Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario, * Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario,
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/ * pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
* admin) agrega la sección Equipo con leads por vendedor. * admin) agrega la sección Equipo con leads por vendedor.
* El rango de fechas (?dias=7|30|90) vive en la URL y filtra leads,
* oportunidades y perfilamiento; inventario y cobranza son posición actual.
*/ */
export default function TableroGeneral({ export default function TableroGeneral({
mostrarEquipo, mostrarEquipo,
@@ -58,23 +77,39 @@ export default function TableroGeneral({
const [resumen, setResumen] = useState<ResumenReportes | null>(null) const [resumen, setResumen] = useState<ResumenReportes | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
// Rango de fechas persistido en la URL: ?dias=7|30|90 (default 30)
const [searchParams, setSearchParams] = useSearchParams()
const paramDias = Number(searchParams.get('dias'))
const dias = (RANGOS_DIAS as readonly number[]).includes(paramDias) ? paramDias : 30
const cargar = useCallback(async () => { const cargar = useCallback(async () => {
setCargando(true) setCargando(true)
try { try {
setResumen(await fetchResumen()) setResumen(await fetchResumen(dias))
setError(null) setError(null)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero') setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
} finally { } finally {
setCargando(false) setCargando(false)
} }
}, []) }, [dias])
useEffect(() => { useEffect(() => {
void cargar() void cargar()
}, [cargar]) }, [cargar])
/** Cambia el rango y lo persiste en la URL (compartible) */
const cambiarRango = (nuevo: number) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
next.set('dias', String(nuevo))
return next
},
{ replace: true },
)
}
// Expone "Actualizar" al header sticky de DashboardPage // Expone "Actualizar" al header sticky de DashboardPage
useEffect(() => { useEffect(() => {
alCambiarAcciones({ actualizar: () => void cargar(), cargando }) alCambiarAcciones({ actualizar: () => void cargar(), cargando })
@@ -89,13 +124,33 @@ export default function TableroGeneral({
return ( return (
<main className="px-4 py-8 sm:px-6 lg:px-8"> <main className="px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-8"> <div className="mb-8 flex flex-wrap items-end justify-between gap-4">
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1> <div>
{resumen && ( <h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]"> {resumen && (
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} <p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
</p> Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
)} </p>
)}
</div>
<div className="flex items-center gap-1" role="group" aria-label="Rango de fechas de las gráficas">
<span className="mr-1 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Rango:</span>
{RANGOS_DIAS.map((r) => (
<button
key={r}
type="button"
onClick={() => cambiarRango(r)}
aria-pressed={dias === r}
className={
dias === r
? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors'
: 'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#64748B] transition-colors hover:text-[#111827] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#94A3B8] dark:hover:text-[#E2E8F0]'
}
>
{r} días
</button>
))}
</div>
</div> </div>
{error && ( {error && (
@@ -114,6 +169,7 @@ export default function TableroGeneral({
titulo="Leads totales" titulo="Leads totales"
valor={resumen.leads.total} valor={resumen.leads.total}
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`} detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
onClick={() => irAApp(RUTA_MI_DIA)}
/> />
<KpiCard <KpiCard
titulo="Oportunidades activas" titulo="Oportunidades activas"
@@ -132,7 +188,8 @@ export default function TableroGeneral({
<KpiCard <KpiCard
titulo="Cartera vencida" titulo="Cartera vencida"
valor={formatoMoneda(resumen.cobranza.montoVencido)} valor={formatoMoneda(resumen.cobranza.montoVencido)}
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s)`} detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s) · click para ver planes`}
onClick={() => irAApp(RUTA_PLANES_VENCIDOS)}
/> />
<KpiCard <KpiCard
titulo="Planes activos" titulo="Planes activos"

View File

@@ -79,3 +79,40 @@ export function funnelMisLeads(misLeads: LeadMiDia[]): EtapaFunnelPersonal[] {
.map(([etapa, valor]) => ({ etapa, valor })) .map(([etapa, valor]) => ({ etapa, valor }))
.sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa)) .sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa))
} }
// ---------- Tablero personal (GET /api/reportes/mi-tablero) ----------
// Mismo endpoint que consume DashboardVendedor en la app de Mi día.
export interface MiTablero {
generadoEn: string
leadsPorDia: { fecha: string; cantidad: number }[]
conversion: {
leads: number
oportunidades: number
apartados: number
vendidos: number
/** Leads con etapa terminal DESCARTADO; etapa extra al final del funnel */
descartados: number
}
ventasMes: { monto: number; unidades: number }
porOrigen: { origen: string; total: number; convertidos: number }[]
porDesarrollo: { desarrollo: string; ventas: number }[]
actividadesHoy: number
actividadesPendientes: number
}
/**
* Etapas del funnel personal desde el endpoint unificado: Leads →
* Oportunidades → Apartados → Vendidos, con "Descartados" al final (fuera del
* embudo: leads terminados sin conversión) para que no se omitan.
*/
export function funnelMiTablero(tablero: MiTablero): EtapaFunnelPersonal[] {
const c = tablero.conversion
return [
{ etapa: 'Leads', valor: c.leads },
{ etapa: 'Oportunidades', valor: c.oportunidades },
{ etapa: 'Apartados', valor: c.apartados },
{ etapa: 'Vendidos', valor: c.vendidos },
{ etapa: 'Descartados', valor: c.descartados },
]
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast' import { Toaster } from 'react-hot-toast'
import '@fontsource/inter/300.css' import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css' import '@fontsource/inter/400.css'
@@ -11,9 +12,13 @@ import DashboardPage from './pages/DashboardPage'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AuthGate> {/* La app vive bajo el prefijo /dash/ de NGINX (vite base); el rango de
<DashboardPage /> fechas del tablero general se persiste en la URL (?dias=7|30|90) */}
</AuthGate> <BrowserRouter basename={import.meta.env.BASE_URL}>
<AuthGate>
<DashboardPage />
</AuthGate>
</BrowserRouter>
<Toaster position="top-right" toastOptions={{ duration: 3000 }} /> <Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,18 +1,32 @@
// Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX) // Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX)
import { conAuth, manejar401 } from './auth' import { conAuth, manejar401 } from './auth'
import type { ResumenReportes } from '../lib/reportes' import type { ResumenReportes } from '../lib/reportes'
import type { MiTablero } from '../lib/mi-tablero'
const API_URL = import.meta.env.VITE_API_URL || '/api' const API_URL = import.meta.env.VITE_API_URL || '/api'
/** GET /reportes/resumen; lanza Error con mensaje legible si el API falla */ async function obtener<T>(path: string): Promise<T> {
export async function fetchResumen(): Promise<ResumenReportes> {
let res: Response let res: Response
try { try {
res = await fetch(`${API_URL}/reportes/resumen`, conAuth()) res = await fetch(`${API_URL}${path}`, conAuth())
} catch { } catch {
throw new Error('No se pudo conectar con el servidor') throw new Error('No se pudo conectar con el servidor')
} }
manejar401(res) manejar401(res)
if (!res.ok) throw new Error(`El API respondió ${res.status}`) if (!res.ok) throw new Error(`El API respondió ${res.status}`)
return (await res.json()) as ResumenReportes return (await res.json()) as T
}
/**
* GET /reportes/resumen; `dias` (7/30/90) acota leads y oportunidades al rango.
* Lanza Error con mensaje legible si el API falla.
*/
export async function fetchResumen(dias?: number): Promise<ResumenReportes> {
const query = dias !== undefined && dias > 0 ? `?dias=${Math.floor(dias)}` : ''
return obtener<ResumenReportes>(`/reportes/resumen${query}`)
}
/** GET /reportes/mi-tablero — tablero personal del vendedor (mismo que Mi día) */
export async function fetchMiTablero(): Promise<MiTablero> {
return obtener<MiTablero>('/reportes/mi-tablero')
} }

View File

@@ -1,192 +0,0 @@
import { useEffect, useState } from 'react'
import { getContactoDetalle, type DetalleContacto, type RelacionCrm } from '../services/api'
interface Props {
contactoId: string
nombre: string
onCerrar: () => void
}
const formatoMoneda = (n: number): string =>
n > 0 ? n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) : '—'
const formatoFecha = (iso: string): string =>
iso
? new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
: ''
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
if (valor === '') return null
return (
<div>
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">{etiqueta}</p>
<p className="mt-0.5 text-sm text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
</div>
)
}
function ListaRelaciones({ titulo, items }: { titulo: string; items: RelacionCrm[] }) {
if (items.length === 0) return null
return (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<ul className="mt-2 space-y-2">
{items.map((r) => (
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-[#E5E7EB] px-3 py-2 dark:border-[#334155]">
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">
{r.estado || 'Sin estado'}
{r.detalle ? <span className="ml-2 text-xs text-[#64748B] dark:text-[#94A3B8]">{r.detalle}</span> : null}
</span>
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{r.atendidoPor ?? 'Sin asignar'}
</span>
</li>
))}
</ul>
</div>
)
}
/**
* Modal de contacto: toda su información (columnas del board), la propiedad
* comprada/en proceso, quién atiende cada item relacionado y el historial
* completo de atención (Monday + capturas de las apps).
*/
export function ModalContacto({ contactoId, nombre, onCerrar }: Props) {
const [detalle, setDetalle] = useState<DetalleContacto | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let vivo = true
getContactoDetalle(contactoId)
.then((d) => {
if (vivo) setDetalle(d)
})
.catch(() => {
if (vivo) setError('No se pudo cargar el detalle del contacto.')
})
return () => {
vivo = false
}
}, [contactoId])
// Cerrar con Escape
useEffect(() => {
const alTeclear = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCerrar()
}
window.addEventListener('keydown', alTeclear)
return () => window.removeEventListener('keydown', alTeclear)
}, [onCerrar])
const c = detalle?.contacto ?? {}
return (
<div
className="fixed inset-0 z-20 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
role="dialog"
aria-modal="true"
aria-label={`Detalle de ${nombre}`}
onClick={(e) => {
if (e.target === e.currentTarget) onCerrar()
}}
>
<div className="w-full max-w-2xl rounded-2xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] -xl">
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
{c['Estado'] && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">{c['Estado']}</span>
)}
</div>
<button
type="button"
onClick={onCerrar}
aria-label="Cerrar"
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] hover:text-[#111827] dark:hover:bg-[#334155] dark:hover:text-[#E2E8F0]"
>
✕
</button>
</div>
<div className="space-y-6 px-6 py-5">
{error && (
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</p>
)}
{!detalle && !error && <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando detalle…</p>}
{detalle && (
<>
<section aria-label="Datos del contacto">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Datos del contacto</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
<Dato etiqueta="Teléfono" valor={c['Teléfono'] ?? ''} />
<Dato etiqueta="Email" valor={c['Email'] ?? ''} />
<Dato etiqueta="CURP" valor={c['CURP'] ?? ''} />
<Dato etiqueta="RFC" valor={c['RFC'] ?? ''} />
<Dato etiqueta="Estado civil" valor={c['Estado Civil'] ?? ''} />
<Dato etiqueta="Fecha de nacimiento" valor={c['Fecha Nacimiento'] ?? ''} />
<Dato etiqueta="Rango de edad" valor={c['Rango de Edad'] ?? ''} />
<Dato etiqueta="Actividad económica" valor={c['Actividad Economica'] ?? ''} />
<Dato etiqueta="Vivienda actual" valor={c['Vivienda Actual'] ?? ''} />
<Dato etiqueta="Propósito de compra" valor={c['Proposito de Compra'] ?? ''} />
<Dato etiqueta="Dirección" valor={c['Dirección'] ?? ''} />
</div>
</section>
{detalle.propiedad && (
<section aria-label="Propiedad">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Propiedad</p>
<div className="rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] p-4 dark:border-[#334155] dark:bg-[#0F172A]">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3">
<Dato etiqueta="Desarrollo" valor={detalle.propiedad.desarrollo} />
<Dato etiqueta="Unidad" valor={detalle.propiedad.unidadCodigo} />
<Dato etiqueta="Precio final" valor={formatoMoneda(detalle.propiedad.precioFinal)} />
<Dato etiqueta="Estatus" valor={detalle.propiedad.estado} />
<Dato etiqueta="Fecha de compra" valor={detalle.propiedad.fechaCompra} />
<Dato etiqueta="Atendido por" valor={detalle.propiedad.atendidoPor ?? ''} />
</div>
</div>
</section>
)}
<section aria-label="Relaciones CRM" className="space-y-4">
<ListaRelaciones titulo="Leads" items={detalle.relaciones.leads} />
<ListaRelaciones titulo="Oportunidades" items={detalle.relaciones.oportunidades} />
<ListaRelaciones titulo="Apartados" items={detalle.relaciones.apartados} />
</section>
<section aria-label="Historial de atención">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Historial de atención
</p>
{detalle.historial.length === 0 ? (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin movimientos registrados.</p>
) : (
<ul className="max-h-64 space-y-2 overflow-y-auto">
{detalle.historial.map((h, i) => (
<li key={i} className="flex items-baseline justify-between gap-3 rounded-lg border border-[#E5E7EB] px-3 py-2 dark:border-[#334155]">
<div className="min-w-0">
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">
{h.detalle}
<span className="ml-2 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-[#334155] dark:text-[#94A3B8]">
{h.fuente}
</span>
</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{h.usuario ?? 'Automatización'}</p>
</div>
<span className="shrink-0 text-xs text-[#94A3B8]">{formatoFecha(h.fechaISO)}</span>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { getContactoDetalle, type DetalleContacto } from '../services/api' import { getContactoDetalle, type DatosContacto, type DetalleContacto } from '../services/api'
const formatoFecha = (iso: string): string => const formatoFecha = (iso: string): string =>
iso iso
@@ -47,7 +47,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
return () => window.removeEventListener('keydown', alTeclear) return () => window.removeEventListener('keydown', alTeclear)
}, [onCerrar]) }, [onCerrar])
const c = detalle?.contacto ?? {} const c: Partial<DatosContacto> = detalle?.contacto ?? {}
const prop = detalle?.propiedad ?? null const prop = detalle?.propiedad ?? null
const hist = detalle?.historial ?? [] const hist = detalle?.historial ?? []
const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] } const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] }
@@ -64,7 +64,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4"> <div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p> <p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p>
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2> <h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c.nombre ?? nombre}</h2>
</div> </div>
<button <button
type="button" type="button"
@@ -83,15 +83,15 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
<div className="mt-5 space-y-5"> <div className="mt-5 space-y-5">
{/* Datos personales */} {/* Datos personales */}
<Section titulo="Datos personales"> <Section titulo="Datos personales">
<Field label="Email" value={c['Email']} /> <Field label="Email" value={c.email} />
<Field label="Teléfono" value={c['Teléfono']} /> <Field label="Teléfono" value={c.telefono} />
<Field label="Estado" value={c['Estado']} /> <Field label="Estado" value={c.estado} />
<Field label="CURP" value={c['CURP']} /> <Field label="CURP" value={c.curp} />
<Field label="RFC" value={c['RFC']} /> <Field label="RFC" value={c.rfc} />
<Field label="Estado civil" value={c['Estado Civil']} /> <Field label="Estado civil" value={c.estadoCivil} />
<Field label="Fecha nacimiento" value={c['Fecha Nacimiento']} /> <Field label="Fecha nacimiento" value={c.fechaNacimiento} />
<Field label="Actividad económica" value={c['Actividad Economica']} /> <Field label="Actividad económica" value={c.actividadEconomica} />
<Field label="Dirección" value={c['Dirección']} /> <Field label="Dirección" value={c.direccion} />
</Section> </Section>
{/* Propiedad */} {/* Propiedad */}

View File

@@ -45,15 +45,15 @@ afterEach(() => {
}) })
describe('ConfigPage', () => { describe('ConfigPage', () => {
it('renderiza los 4 tabs y muestra "Modo de trabajo" por defecto', () => { it('renderiza los 3 tabs y muestra "Roles y permisos" por defecto', async () => {
render(<ConfigPage onVolver={() => {}} />) render(<ConfigPage onVolver={() => {}} />)
expect(screen.getByText('Configuraciones')).toBeTruthy() expect(screen.getByText('Configuraciones')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Modo de trabajo' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Roles y permisos' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Roles y permisos' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy()
// El tab de "Modo de trabajo" está activo por defecto expect(screen.queryByRole('button', { name: 'Modo de trabajo' })).toBeNull()
expect(screen.getByText('Modo de trabajo')).toBeTruthy() // El tab de "Roles y permisos" está activo por defecto (su contenido carga async)
expect(await screen.findByText('Guardar cambios')).toBeTruthy()
}) })
it('al hacer clic en un tab, muestra su contenido', async () => { it('al hacer clic en un tab, muestra su contenido', async () => {

View File

@@ -1,5 +1,4 @@
import { useState } from 'react' import { useState } from 'react'
import { ModoTrabajo } from './ModoTrabajo'
import { RolesPermisos } from './RolesPermisos' import { RolesPermisos } from './RolesPermisos'
import { Usuarios } from './Usuarios' import { Usuarios } from './Usuarios'
import { Sistema } from './Sistema' import { Sistema } from './Sistema'
@@ -10,17 +9,16 @@ interface Props {
onVolver: () => void onVolver: () => void
} }
type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema' | 'auditoria' type Tab = 'roles' | 'usuarios' | 'sistema' | 'auditoria'
const TABS_BASE: { key: Tab; label: string }[] = [ const TABS_BASE: { key: Tab; label: string }[] = [
{ key: 'modo', label: 'Modo de trabajo' },
{ key: 'roles', label: 'Roles y permisos' }, { key: 'roles', label: 'Roles y permisos' },
{ key: 'usuarios', label: 'Usuarios' }, { key: 'usuarios', label: 'Usuarios' },
{ key: 'sistema', label: 'Sistema' }, { key: 'sistema', label: 'Sistema' },
] ]
export function ConfigPage({ onVolver }: Props) { export function ConfigPage({ onVolver }: Props) {
const [tab, setTab] = useState<Tab>('modo') const [tab, setTab] = useState<Tab>('roles')
const esAdmin = getSesion()?.usuario.rol === 'admin' const esAdmin = getSesion()?.usuario.rol === 'admin'
const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE
@@ -62,7 +60,6 @@ export function ConfigPage({ onVolver }: Props) {
{/* Contenido del tab activo */} {/* Contenido del tab activo */}
<section className="mt-6"> <section className="mt-6">
{tab === 'modo' && <ModoTrabajo />}
{tab === 'roles' && <RolesPermisos />} {tab === 'roles' && <RolesPermisos />}
{tab === 'usuarios' && <Usuarios />} {tab === 'usuarios' && <Usuarios />}
{tab === 'sistema' && <Sistema />} {tab === 'sistema' && <Sistema />}

View File

@@ -1,85 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ModoTrabajo } from './ModoTrabajo'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() {
return mapa.size
},
} as Storage
}
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('ModoTrabajo', () => {
it('muestra el estado de Monday y el toggle', async () => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
if (url.includes('/config/sistema')) return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
return Promise.resolve(respuestaOk({}))
})
render(<ModoTrabajo />)
await waitFor(() => {
expect(screen.getByText(/Conectado|No disponible/)).toBeTruthy()
}, { timeout: 5000 })
expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy()
})
it('al hacer clic en el toggle, hace PATCH y actualiza el texto', async () => {
fetchMock.mockImplementation((input, init) => {
const url = String(input)
if (url.includes('/sistema/estado')) return Promise.resolve(respuestaOk({ mondayOk: true, pendientesSync: 0 }))
if (url.includes('/config/sistema') && (!init?.method || init.method === 'GET')) {
return Promise.resolve(respuestaOk({ modo_local_forzado: 'false' }))
}
if (url.includes('/config/sistema') && init?.method === 'PATCH') {
return Promise.resolve(respuestaOk({}))
}
return Promise.resolve(respuestaOk({}))
})
render(<ModoTrabajo />)
await waitFor(() => {
expect(screen.getByRole('switch', { name: /cambiar modo de trabajo/i })).toBeTruthy()
}, { timeout: 5000 })
fireEvent.click(screen.getByRole('switch', { name: /cambiar modo de trabajo/i }))
await waitFor(() => {
const llamadaPatch = fetchMock.mock.calls.find(
([url, init]) => String(url).includes('/config/sistema') && init?.method === 'PATCH',
)
expect(llamadaPatch).toBeTruthy()
}, { timeout: 5000 })
expect(await screen.findByText('Modo local activado')).toBeTruthy()
})
})

View File

@@ -1,155 +0,0 @@
import { useEffect, useState } from 'react'
import { fetchEstadoSistema, type EstadoSistema } from '../../services/sistema'
import { getConfigSistema, patchConfigSistema } from '../../services/config'
import { API_URL, conAuth } from '../../services/auth'
export function ModoTrabajo() {
const [estado, setEstado] = useState<EstadoSistema | null>(null)
const [modoLocal, setModoLocal] = useState(false)
const [cargando, setCargando] = useState(true)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
useEffect(() => {
let activo = true
const cargar = async () => {
setCargando(true)
try {
const [estadoRes, configRes] = await Promise.all([
fetchEstadoSistema(),
getConfigSistema(),
])
if (!activo) return
setEstado(estadoRes)
setModoLocal(configRes.modo_local_forzado === 'true')
} catch {
if (activo) setError('No se pudo cargar el estado del sistema.')
} finally {
if (activo) setCargando(false)
}
}
void cargar()
return () => { activo = false }
}, [])
const toggleModo = async () => {
const nuevoModo = !modoLocal
setGuardando(true)
setAviso('')
setError('')
try {
await patchConfigSistema({ modo_local_forzado: String(nuevoModo) })
setModoLocal(nuevoModo)
setAviso(nuevoModo ? 'Modo local activado' : 'Modo vinculado a Monday restaurado')
} catch {
setError('No se pudo actualizar el modo de trabajo.')
} finally {
setGuardando(false)
}
}
const reconectar = async () => {
setGuardando(true)
setAviso('')
setError('')
try {
// Intenta reconectar — si el endpoint no existe, simplemente informa
const res = await fetch(`${API_URL}/sistema/reconexion`, conAuth({ method: 'POST' }))
if (res.ok) {
setAviso('Reconexión forzada. El estado se actualizará en unos segundos.')
// Recargar estado después de un breve delay
setTimeout(async () => {
const nuevoEstado = await fetchEstadoSistema()
setEstado(nuevoEstado)
}, 3000)
} else {
setAviso('El endpoint de reconexión no está disponible aún.')
}
} catch {
setAviso('El endpoint de reconexión no está disponible aún.')
} finally {
setGuardando(false)
}
}
if (cargando) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando estado del sistema…</p>
</div>
)
}
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Modo de trabajo</h2>
{/* Indicador del estado de Monday */}
<div className="mt-4 flex items-center gap-2">
<span
className={`h-2.5 w-2.5 rounded-full ${estado?.mondayOk ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Monday: {estado?.mondayOk ? 'Conectado' : 'No disponible'}
{estado && ` · ${estado.pendientesSync} pendiente(s) de sincronizar`}
</span>
</div>
{/* Toggle de modo */}
<div className="mt-6 flex items-center gap-4">
<button
type="button"
onClick={() => void toggleModo()}
disabled={guardando}
className={`relative inline-flex h-8 w-14 flex-shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50 ${
modoLocal ? 'bg-amber-500' : 'bg-accent'
}`}
role="switch"
aria-checked={modoLocal}
aria-label="Cambiar modo de trabajo"
>
<span
className={`pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-sm transition-transform duration-200 ${
modoLocal ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
<div>
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{modoLocal ? 'Modo local (sin Monday)' : 'Vinculado a Monday'}
</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
{modoLocal
? 'Las capturas se guardan localmente y se sincronizarán cuando Monday esté disponible.'
: 'Los leads, actividades y contactos se sincronizan con Monday en tiempo real.'}
</p>
</div>
</div>
{/* Botón reconectar */}
<div className="mt-6">
<button
type="button"
onClick={() => void reconectar()}
disabled={guardando}
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] disabled:opacity-50 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
{guardando ? 'Procesando…' : 'Forzar reconexión'}
</button>
</div>
{/* Feedback */}
{aviso && (
<p role="status" className="mt-4 text-sm font-medium text-green-600">
{aviso}
</p>
)}
{error && (
<p role="alert" className="mt-4 text-sm text-red-600">
{error}
</p>
)}
</div>
)
}

View File

@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
} }
describe('MODULOS', () => { describe('MODULOS', () => {
it('expone los 8 módulos del ecosistema', () => { it('expone los 9 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(8) expect(MODULOS).toHaveLength(9)
expect(MODULOS.map((m) => m.id)).toEqual([ expect(MODULOS.map((m) => m.id)).toEqual([
'cotizador', 'cotizador',
'alta-clientes', 'alta-clientes',
@@ -19,6 +19,7 @@ describe('MODULOS', () => {
'mi-dia', 'mi-dia',
'contactos', 'contactos',
'captura', 'captura',
'clientes-maestro',
]) ])
}) })

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro' import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
import { ClientesMaestroTable } from '../components/ClientesMaestroTable' import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
import ModalClienteMaestro from '../components/ModalClienteMaestro' import ModalClienteMaestro from '../components/ModalClienteMaestro'
import { getSesion } from '../services/auth'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
interface Props { interface Props {
@@ -16,7 +15,6 @@ const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCE
/** /**
* Página / clientes-maestro: tabla paginada con filtros de búsqueda, * Página / clientes-maestro: tabla paginada con filtros de búsqueda,
* desarrollo y estatus. Botón exportar genera xlsx desde los datos filtrados. * desarrollo y estatus. Botón exportar genera xlsx desde los datos filtrados.
* Botón importar (solo admin) sube Excel al backend.
*/ */
export function ClientesMaestroPage({ onVolver }: Props) { export function ClientesMaestroPage({ onVolver }: Props) {
const [datos, setDatos] = useState<ClienteMaestro[]>([]) const [datos, setDatos] = useState<ClienteMaestro[]>([])
@@ -29,9 +27,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
const [filtroEstatus, setFiltroEstatus] = useState('') const [filtroEstatus, setFiltroEstatus] = useState('')
const [busquedaDebounced, setBusquedaDebounced] = useState('') const [busquedaDebounced, setBusquedaDebounced] = useState('')
const sesion = getSesion()
const esAdmin = sesion?.usuario.rol === 'admin'
// Debounce búsqueda // Debounce búsqueda
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -102,24 +97,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
} }
} }
const handleImportar = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.xlsx,.xls,.csv'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
toast.loading('Importando...', { id: 'importar' })
try {
// For CSV import we'd need a reader; for now show a message
toast.error('La importación se realiza desde el script del backend', { id: 'importar' })
} catch {
toast.error('Error al importar', { id: 'importar' })
}
}
input.click()
}
return ( return (
<> <>
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16"> <main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
@@ -141,15 +118,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
</p> </p>
)} )}
</div> </div>
{esAdmin && (
<button
type="button"
onClick={handleImportar}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover"
>
📤 Importar
</button>
)}
</div> </div>
{/* Filtros */} {/* Filtros */}

View File

@@ -72,9 +72,24 @@ export interface EventoHistorialContacto {
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App' fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
} }
export interface DatosContacto {
nombre: string
telefono: string
email: string
estado: string
curp?: string
rfc?: string
estadoCivil?: string
fechaNacimiento?: string
actividadEconomica?: string
direccion?: string
/** Cualquier otro campo que el backend incluya */
[campo: string]: string | undefined
}
export interface DetalleContacto { export interface DetalleContacto {
/** Columnas del board por título ('nombre' incluido); '' si vacía */ /** Campos nombrados del modelo local (antes: títulos de columna de Monday) */
contacto: Record<string, string> contacto: DatosContacto
propiedad: { propiedad: {
id: string id: string
unidadCodigo: string unidadCodigo: string

View File

@@ -53,9 +53,9 @@ describe('DashboardVendedor', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Ventas del mes')).toBeTruthy() expect(screen.getByText('Ventas del mes')).toBeTruthy()
}) })
expect(screen.getByText('Actividades de hoy')).toBeTruthy() expect(screen.getByText('Leads nuevos hoy')).toBeTruthy()
expect(screen.getByText('Leads totales')).toBeTruthy() expect(screen.getByText('Leads totales')).toBeTruthy()
expect(screen.getByText('Funnel conversión')).toBeTruthy() expect(screen.getByText('Funnel de conversión')).toBeTruthy()
expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy() expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy()
expect(screen.getByText('Funnel de conversión')).toBeTruthy() expect(screen.getByText('Funnel de conversión')).toBeTruthy()
expect(screen.getByText('Rendimiento por origen')).toBeTruthy() expect(screen.getByText('Rendimiento por origen')).toBeTruthy()

View File

@@ -76,16 +76,14 @@ export default function DashboardVendedor() {
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1 const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
// leadsPorDia es el único histórico mensual del tablero (últimos 30 días);
// se agrupa por mes parseando 'YYYY-MM-DD' sin Date (evita corrimientos UTC)
let leadsMesActual = 0 let leadsMesActual = 0
let leadsMesAnterior = 0 let leadsMesAnterior = 0
for (const d of tablero.leadsPorDia) { for (const d of tablero.leadsPorDia) {
const fecha = new Date(d.fecha) const [anio, mes] = d.fecha.split('-').map(Number)
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) { if (anio === anioActual && mes === mesActual + 1) leadsMesActual += d.cantidad
leadsMesActual += d.cantidad if (anio === anioAnterior && mes === mesAnterior + 1) leadsMesAnterior += d.cantidad
}
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
leadsMesAnterior += d.cantidad
}
} }
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'] const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
@@ -100,14 +98,16 @@ export default function DashboardVendedor() {
{ {
metrica: 'Oportunidades', metrica: 'Oportunidades',
mesActual: tablero.conversion.oportunidades, mesActual: tablero.conversion.oportunidades,
mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado // Sin histórico mensual: no se estima el mes anterior
mesAnterior: null,
nombreMesActual: nombresMes[mesActual], nombreMesActual: nombresMes[mesActual],
nombreMesAnterior: nombresMes[mesAnterior], nombreMesAnterior: nombresMes[mesAnterior],
}, },
{ {
metrica: 'Ventas', metrica: 'Ventas',
mesActual: tablero.ventasMes.unidades, mesActual: tablero.ventasMes.unidades,
mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado // Sin histórico mensual: no se estima el mes anterior
mesAnterior: null,
nombreMesActual: nombresMes[mesActual], nombreMesActual: nombresMes[mesActual],
nombreMesAnterior: nombresMes[mesAnterior], nombreMesAnterior: nombresMes[mesAnterior],
}, },
@@ -211,6 +211,9 @@ export default function DashboardVendedor() {
{/* F1: Comparativa mensual */} {/* F1: Comparativa mensual */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2"> <div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion> <TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
<p className="-mt-2 mb-3 text-xs text-[#94A3B8]">
El mes anterior solo se compara en leads (único histórico disponible); oportunidades y ventas muestran el mes en curso.
</p>
<ResponsiveContainer width="100%" height={260}> <ResponsiveContainer width="100%" height={260}>
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}> <BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />

View File

@@ -27,14 +27,23 @@ const CLASE_ACCION_RAPIDA =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10' 'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [ const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
{ id: 'info', titulo: 'Información del cliente potencial', icono: User }, { id: 'info', titulo: 'Información', icono: User },
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar }, { id: 'cita', titulo: 'Cita', icono: Calendar },
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare }, { id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
{ id: 'notas', titulo: 'Notas', icono: FileText }, { id: 'notas', titulo: 'Notas', icono: FileText },
{ id: 'miembros', titulo: 'Objetos de los miembros', icono: Users }, { id: 'miembros', titulo: 'Miembros', icono: Users },
] ]
export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) { export default function DetalleLeadModal({
item,
onCerrar,
onGuardarCita,
}: {
item: LeadItem
onCerrar: () => void
/** Guarda la cita de una oportunidad (fechaHora 'YYYY-MM-DDTHH:mm'); la página maneja el error */
onGuardarCita?: (fechaHora: string) => Promise<void> | void
}) {
const esOp = esOportunidad(item) const esOp = esOportunidad(item)
const esLd = esLead(item) const esLd = esLead(item)
const vendedor = getSesion()?.usuario.nombre ?? '' const vendedor = getSesion()?.usuario.nombre ?? ''
@@ -49,6 +58,23 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
const [busquedaNota, setBusquedaNota] = useState('') const [busquedaNota, setBusquedaNota] = useState('')
// --- Tareas --- // --- Tareas ---
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null) const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
// --- Cita ---
const [fechaCita, setFechaCita] = useState('')
const [guardandoCita, setGuardandoCita] = useState(false)
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
async function guardarCita(e: FormEvent) {
e.preventDefault()
if (!onGuardarCita || fechaCita === '') return
setGuardandoCita(true)
try {
await onGuardarCita(fechaCita)
} catch {
// el error ya se muestra en la página (toast/aviso del caller)
} finally {
setGuardandoCita(false)
}
}
const cargarNotas = useCallback(async () => { const cargarNotas = useCallback(async () => {
setCargandoNotas(true) setCargandoNotas(true)
@@ -102,8 +128,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{/* Header */} {/* Header */}
<div className="absolute top-0 left-0 right-0 flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-3 bg-white dark:bg-[#1E293B] z-10"> <div className="absolute top-0 left-0 right-0 flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-3 bg-white dark:bg-[#1E293B] z-10">
<div> <div>
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Editar "{item.nombre}"</h2> <h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Bitácora de {item.nombre}</h2>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Añadir y editar oportunidad Detalles, Actividades, notas y Cita.</p> <p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Seguimiento del cliente: información, citas, tareas y notas</p>
</div> </div>
<button type="button" onClick={onCerrar} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#334155]"><X className="h-5 w-5" /></button> <button type="button" onClick={onCerrar} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#334155]"><X className="h-5 w-5" /></button>
</div> </div>
@@ -168,14 +194,37 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{tab === 'cita' && ( {tab === 'cita' && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Reserve o actualice una cita</h3> <h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Cita</h3>
{esOp && item.fechaCita ? ( {esOp && item.fechaCita ? (
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4"> <div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p> <p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p> <p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
</div> </div>
) : esOp ? (
<form onSubmit={guardarCita} className="space-y-3">
<div>
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Fecha y hora de la cita
</label>
<input
id="cita-fecha"
type="datetime-local"
value={fechaCita}
onChange={(e) => setFechaCita(e.target.value)}
disabled={guardandoCita}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
/>
</div>
<button
type="submit"
disabled={guardandoCita || fechaCita === ''}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{guardandoCita ? 'Guardando…' : 'Guardar cita'}
</button>
</form>
) : ( ) : (
<p className="text-sm text-[#94A3B8]">No hay cita programada. Use los botones de fase en la lista para agendar una.</p> <p className="text-sm text-[#94A3B8]">No hay cita programada. Las citas se agendan desde Mis oportunidades.</p>
)} )}
</div> </div>
)} )}
@@ -267,7 +316,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{tab === 'miembros' && ( {tab === 'miembros' && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Objetos de los miembros</h3> <h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Miembros</h3>
<p className="text-sm text-[#94A3B8]">Información de los miembros del equipo asignados a este lead.</p> <p className="text-sm text-[#94A3B8]">Información de los miembros del equipo asignados a este lead.</p>
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4"> <div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
<p className="text-xs text-[#94A3B8]">Vendedor: {(item as any).vendedor ?? '—'}</p> <p className="text-xs text-[#94A3B8]">Vendedor: {(item as any).vendedor ?? '—'}</p>

View File

@@ -1,5 +1,4 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import ScoreBadge from './ScoreBadge'
/** Columna genérica del kanban */ /** Columna genérica del kanban */
interface ColumnaDef { interface ColumnaDef {
@@ -77,7 +76,6 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
<> <>
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p> <p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
{'score' in item && <ScoreBadge score={Number((item as Record<string, unknown>).score) || 0} />}
</div> </div>
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>} {item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>} {item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}

View File

@@ -1,69 +0,0 @@
/** Badge circular con score 0-100 para lead scoring. */
interface ScoreBadgeProps {
score: number
size?: 'sm' | 'md'
}
function colorForScore(score: number): string {
if (score > 70) return 'bg-green-500 text-white'
if (score > 40) return 'bg-yellow-400 text-yellow-900'
return 'bg-red-500 text-white'
}
/**
* Calcula el score de un lead de forma local usando:
* etapa (20-90) + recencia + teléfono + email + origen.
*
* Para oportunidades usa la `etapa`; para leads usa `estado`.
*/
export function calcularScore(lead: {
estado?: string
etapa?: string
telefono?: string
email?: string
origen?: string
updatedAt?: string
}): number {
const etapa = lead.etapa ?? lead.estado ?? ''
const normalizada = etapa.toUpperCase()
// Base por etapa: 20-90
let base = 20
if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60
else if (normalizada.includes('INTERES')) base = 50
else if (normalizada.includes('CONTACTAD')) base = 40
else if (normalizada.includes('COTIZ')) base = 70
else if (normalizada.includes('APARTO') || normalizada.includes('APARTAD')) base = 90
else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5
// Recencia
let recencia = 0
if (lead.updatedAt) {
const diffMs = Date.now() - new Date(lead.updatedAt).getTime()
const diffHoras = diffMs / (1000 * 60 * 60)
if (diffHoras < 24) recencia = 10
else if (diffHoras < 168) recencia = 5
else if (diffHoras > 720) recencia = -20 // >30 días
}
// Señales extra
const tel = lead.telefono && lead.telefono.trim() !== '' ? 5 : 0
const em = lead.email && lead.email.trim() !== '' ? 5 : 0
const ori = lead.origen && lead.origen.trim() !== '' ? 5 : 0
return Math.max(0, Math.min(100, base + recencia + tel + em + ori))
}
export default function ScoreBadge({ score, size = 'sm' }: ScoreBadgeProps) {
const dim = size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-8 w-8 text-xs'
return (
<span
className={`inline-flex items-center justify-center rounded-full font-bold ${dim} ${colorForScore(score)}`}
title={`Score: ${score}`}
aria-label={`Score: ${score}`}
>
{score}
</span>
)
}

View File

@@ -112,50 +112,57 @@ export interface ResumenMiDia {
} }
} }
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades), /**
// vocabulario FRASA verificado y sembrado en Monday el 2026-09-04 * VOCABULARIO canónico FRASA: única fuente de verdad de fases, estados,
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const * etapas, motivos, subcategorías, orígenes y desarrollos. Las constantes
* individuales (FASES_OPORTUNIDAD, MOTIVOS_NO_COMPRA…) se derivan de aquí.
* Espejo del backend en api-gateway/src/config/vocabulario.ts (mantener
* sincronizados).
*/
export const VOCABULARIO = {
// Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del
// API + NUEVO/DESCARTADO)
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
// Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
FASES_APARTADO: ['Pago Apartado', 'Alta Enkontrol', 'Entrego Documentacion', 'Cita Clientes', 'Contrato Enkontrol', 'Firma Cliente', 'Cancelacion Apartado'],
// Fases de escrituración (pipeline posterior a 'Firma Cliente')
FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'],
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA)
MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'],
// Subcategorías por categoría (catálogo del tablero Motivos No Compra,
// sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes'
// conservan las faltas de ortografía originales del tablero del cliente)
SUBCATEGORIAS: {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
},
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
} as const
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday) // Constantes derivadas (nombres estables que consume el resto de la app)
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const export const ORIGENES_LEAD = VOCABULARIO.ORIGENES
// Estados de la columna status del board Leads (vocabulario real del API: export const DESARROLLOS_LEAD = VOCABULARIO.DESARROLLOS
// ESTADOS_LEAD_PERMITIDOS + NUEVO/DESCARTADO); alimenta el filtro por estado
export const ESTADOS_LEAD = [
'NUEVO',
'CONTACTADO',
'Intento de contacto',
'INTERESADO',
'AGENDA CITA',
'CALIFICADO',
'DESCARTADO',
] as const
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API) // Alimenta el filtro por estado de Mis leads
export const MOTIVOS_NO_COMPRA = [ export const ESTADOS_LEAD = VOCABULARIO.FASES_LEAD
'Precio',
'Producto', export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA
'Ubicacion',
'Financiamiento',
'Competencia',
'Cliente',
'Timing',
] as const
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number] export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
// Subcategorías por categoría del tablero Motivos No Compra (catálogo del export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
// workspace de referencia CRM FRASA, sembrado en Frasa-CAS el 2026-09-04).
// Espejo de SUBCATEGORIAS_NO_COMPRA del API (mantener sincronizados).
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, string[]> = {
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
}
// LADAs soportadas en la captura de teléfonos (México default, USA opcional) // LADAs soportadas en la captura de teléfonos (México default, USA opcional)
export const LADAS = [ export const LADAS = [
@@ -193,20 +200,11 @@ export function urlWhatsApp(nombre: string, telefono: string, vendedor: string):
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}` return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
} }
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual) // Fases del funnel de Oportunidades y del pipeline Enkontrol (del VOCABULARIO)
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const export const FASES_OPORTUNIDAD = VOCABULARIO.FASES_OPORTUNIDAD
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number] export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board) export const FASES_APARTADO = VOCABULARIO.FASES_APARTADO
export const FASES_APARTADO = [
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
] as const
export type FaseApartado = (typeof FASES_APARTADO)[number] export type FaseApartado = (typeof FASES_APARTADO)[number]
// Texto bonito de los botones (el valor que se manda al API es el label real) // Texto bonito de los botones (el valor que se manda al API es el label real)
@@ -220,17 +218,7 @@ export const ETIQUETA_FASE_APARTADO: Record<FaseApartado, string> = {
'Cancelacion Apartado': 'Cancelación', 'Cancelacion Apartado': 'Cancelación',
} }
export const FASES_ESCRITURACION = [ export const FASES_ESCRITURACION = VOCABULARIO.FASES_ESCRITURACION
'Venta',
'Seguimiento',
'Integracion',
'X Autorizar',
'Autorizado',
'X Escriturar',
'Escriturado',
'Titulado',
'Cancelo',
] as const
export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number] export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number]
export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = { export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = {

View File

@@ -51,7 +51,6 @@ import { exportarLeads, exportarOportunidades, exportarApartados } from '../serv
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados' import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth' import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor' import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
import KanbanView from '../components/KanbanView' import KanbanView from '../components/KanbanView'
import CalendarioCitas from '../components/CalendarioCitas' import CalendarioCitas from '../components/CalendarioCitas'
import DetalleLeadModal from '../components/DetalleLeadModal' import DetalleLeadModal from '../components/DetalleLeadModal'
@@ -618,7 +617,6 @@ function FilaLead({
> >
{lead.nombre} {lead.nombre}
</button> </button>
<ScoreBadge score={calcularScore(lead)} />
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */} {/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
<span <span
@@ -1677,6 +1675,17 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<DetalleLeadModal <DetalleLeadModal
item={leadSeleccionado} item={leadSeleccionado}
onCerrar={() => setLeadSeleccionado(null)} onCerrar={() => setLeadSeleccionado(null)}
onGuardarCita={
'modeloInteres' in leadSeleccionado
? (fechaHora) => {
const op = leadSeleccionado
return ejecutar(
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora),
'No se pudo guardar la cita',
)
}
: undefined
}
/> />
)} )}
</> </>