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:
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "AplicacionPago" ADD COLUMN "capturadoPorId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AplicacionPago_capturadoPorId_idx" ON "AplicacionPago"("capturadoPorId");
|
||||
@@ -142,8 +142,12 @@ model AplicacionPago {
|
||||
fechaPago DateTime
|
||||
monto Float
|
||||
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([capturadoPorId])
|
||||
}
|
||||
|
||||
// Vinculación lead ↔ oportunidad del funnel: cada lead capturado entra al
|
||||
|
||||
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import dotenv from 'dotenv'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
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)), '..')
|
||||
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
|
||||
@@ -31,19 +31,8 @@ const CONFIGS_DEFAULT = [
|
||||
{ clave: 'round_robin_habilitado', valor: 'false' },
|
||||
]
|
||||
|
||||
// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ──────
|
||||
const MODULOS_VENDEDOR = [
|
||||
'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],
|
||||
}
|
||||
// ── RolConfiguracion: módulos por rol (la MATRIZ de config/roles.ts es la
|
||||
// fuente de verdad; aquí solo se persiste para que /auth/me la lea de DB) ──
|
||||
|
||||
async function main() {
|
||||
console.log('⚙️ Sembrando ConfiguracionSistema...')
|
||||
@@ -57,7 +46,7 @@ async function main() {
|
||||
}
|
||||
|
||||
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
|
||||
await prisma.rolConfiguracion.upsert({
|
||||
where: { rol },
|
||||
|
||||
68
api-gateway/src/config/vocabulario.ts
Normal file
68
api-gateway/src/config/vocabulario.ts
Normal 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]
|
||||
@@ -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 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('detalleContactoEndpoint (fallback local)', () => {
|
||||
|
||||
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
|
||||
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['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
|
||||
expect(Array.isArray(detalle['historial'])).toBe(true)
|
||||
|
||||
@@ -185,6 +185,7 @@ type MondayLeads = Pick<
|
||||
| 'buscarLeadPorTelefono'
|
||||
| 'buscarOportunidadPorTelefono'
|
||||
| 'getLeads'
|
||||
| 'getOportunidades'
|
||||
| 'labelsDeColumna'
|
||||
| 'obtenerNombreItem'
|
||||
| 'columnaIdPorTitulo'
|
||||
|
||||
@@ -12,9 +12,6 @@ const prismaMock = vi.hoisted(() => ({
|
||||
leadCaptura: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
leadOportunidad: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
cliente: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
@@ -36,7 +33,6 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||
|
||||
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
|
||||
import { saludMonday } from '../services/salud-monday.service.js'
|
||||
|
||||
const usuarioFake = {
|
||||
id: 'u-1',
|
||||
@@ -80,12 +76,9 @@ const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
|
||||
saludMonday.restablecer()
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
|
||||
prismaMock.usuario.findMany.mockResolvedValue([])
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
|
||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||
prismaMock.actividadLocal.findMany.mockResolvedValue([])
|
||||
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
|
||||
@@ -96,9 +89,8 @@ beforeEach(() => {
|
||||
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
|
||||
})
|
||||
|
||||
describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => {
|
||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
||||
describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
|
||||
it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', 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.getOportunidades).not.toHaveBeenCalled()
|
||||
expect(mondayMock.getApartados).not.toHaveBeenCalled()
|
||||
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
|
||||
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['misActividadesHoy']).toEqual([])
|
||||
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
|
||||
})
|
||||
|
||||
it('Monday se cae a media lectura (502): cae al resumen local', async () => {
|
||||
mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
||||
it('las capturas locales llegan con pendienteSync (badge ⏳ del front)', async () => {
|
||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
|
||||
])
|
||||
@@ -126,41 +119,11 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
|
||||
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 () => {
|
||||
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 () => {
|
||||
it('sin capturas: listas vacías y sin flags de sync', async () => {
|
||||
const { res, json } = resFake()
|
||||
|
||||
await resumenMiDiaEndpoint(reqFake, res)
|
||||
@@ -170,8 +133,20 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
||||
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
|
||||
})
|
||||
|
||||
it('un error no-Monday se propaga (no se traga nada)', async () => {
|
||||
mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug'))
|
||||
it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { mondayService } from '../services/monday.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 { resumenMiDiaLocal, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||
|
||||
/**
|
||||
* GET /api/mi-dia/resumen — agenda del vendedor logueado.
|
||||
* El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday);
|
||||
* sin mapeo, las listas personales van vacías con sinMapeoMonday: true.
|
||||
* SIEMPRE responde desde datos locales (LeadCaptura / Cliente / ActividadLocal):
|
||||
* 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> {
|
||||
// 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' } })
|
||||
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
|
||||
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
@@ -50,27 +26,12 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
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
|
||||
const clientes = await prisma.cliente.findMany({
|
||||
where: esAdmin ? {} : { capturadoPorId: usuario.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
|
||||
|
||||
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local
|
||||
if (!saludMonday.estaDisponible()) {
|
||||
// Actividades locales del usuario
|
||||
const actividadesRaw = await prisma.actividadLocal.findMany({
|
||||
where: { usuarioId: usuario.id },
|
||||
@@ -98,44 +59,7 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
||||
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
|
||||
}
|
||||
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,8 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
|
||||
fechaPago: new Date(body.fechaPago),
|
||||
monto: aplicacion.monto,
|
||||
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({
|
||||
|
||||
@@ -90,7 +90,12 @@ export function crearController(svc: RecordatorioVendedorService) {
|
||||
const usuarioId = req.usuarioToken?.sub
|
||||
if (!usuarioId) throw new Error('Auth requerida')
|
||||
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,
|
||||
hasta: (req.query['hasta'] as string) ?? null,
|
||||
}
|
||||
|
||||
@@ -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) */
|
||||
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
|
||||
|
||||
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */
|
||||
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await reportesService.resumen())
|
||||
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta
|
||||
* ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */
|
||||
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 */
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
|
||||
rol: 'vendedor',
|
||||
asignacion: 'MONTEBELLO',
|
||||
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: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('login', () => {
|
||||
rol: 'vendedor',
|
||||
asignacion: 'MONTEBELLO',
|
||||
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: [],
|
||||
})
|
||||
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 () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ 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 () => {
|
||||
|
||||
@@ -602,7 +602,7 @@ export class AutomatizacionesService {
|
||||
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) {
|
||||
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@ describe('detalleContacto (GET /api/contactos/:id/detalle)', () => {
|
||||
},
|
||||
)
|
||||
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)
|
||||
expect(d!.propiedad).toMatchObject({
|
||||
unidadCodigo: 'SENDA-409-1-2',
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface EventoHistorialContacto {
|
||||
}
|
||||
|
||||
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) */
|
||||
propiedad: {
|
||||
id: string
|
||||
@@ -57,6 +57,32 @@ export function claveTelefono(telefono: string): string {
|
||||
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<
|
||||
MondayService,
|
||||
| 'getContactoDetalle'
|
||||
@@ -169,7 +195,7 @@ export async function detalleContacto(
|
||||
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||||
|
||||
return {
|
||||
contacto,
|
||||
contacto: normalizarCamposContacto(contacto),
|
||||
propiedad,
|
||||
relaciones: {
|
||||
leads: relLeads.map((l) => ({
|
||||
|
||||
@@ -162,10 +162,11 @@ describe('detalleContactoLocal', () => {
|
||||
|
||||
expect(detalle.contacto).toMatchObject({
|
||||
nombre: 'JUAN PÉREZ',
|
||||
Teléfono: '4771234567',
|
||||
Email: 'juan@correo.mx',
|
||||
CURP: 'CURP123',
|
||||
RFC: 'RFC123',
|
||||
telefono: '4771234567',
|
||||
email: 'juan@correo.mx',
|
||||
estado: 'Cliente',
|
||||
curp: 'CURP123',
|
||||
rfc: 'RFC123',
|
||||
})
|
||||
expect(detalle.propiedad).toMatchObject({
|
||||
id: 'loc_a1',
|
||||
@@ -203,7 +204,7 @@ describe('detalleContactoLocal', () => {
|
||||
const detalle = await detalleContactoLocal('loc_1')
|
||||
expect(detalle).not.toBeNull()
|
||||
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.relaciones.oportunidades).toHaveLength(1)
|
||||
expect(detalle.historial.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -202,17 +202,17 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
||||
cliente !== null
|
||||
? {
|
||||
nombre: cliente.nombreCompleto,
|
||||
Teléfono: cliente.celular ?? '',
|
||||
Email: cliente.email ?? '',
|
||||
CURP: cliente.curp,
|
||||
RFC: cliente.rfc,
|
||||
Estado: 'Cliente',
|
||||
telefono: cliente.celular ?? '',
|
||||
email: cliente.email ?? '',
|
||||
estado: 'Cliente',
|
||||
curp: cliente.curp,
|
||||
rfc: cliente.rfc,
|
||||
}
|
||||
: {
|
||||
nombre: capturaBase!.nombreLead,
|
||||
Teléfono: capturaBase!.telefono ?? '',
|
||||
Email: capturaBase!.email ?? '',
|
||||
Estado: capturaBase!.etapa,
|
||||
telefono: capturaBase!.telefono ?? '',
|
||||
email: capturaBase!.email ?? '',
|
||||
estado: capturaBase!.etapa,
|
||||
}
|
||||
|
||||
return { contacto, propiedad, relaciones, historial }
|
||||
|
||||
@@ -218,7 +218,14 @@ export async function resumenMiDia(
|
||||
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
|
||||
nombresPorMondayId: Map<number, string>,
|
||||
/** 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: {
|
||||
id: string
|
||||
@@ -332,7 +339,10 @@ export async function resumenMiDia(
|
||||
)
|
||||
// Set de oportunidades mías (por LeadCaptura o por vendedorIds)
|
||||
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) =>
|
||||
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
|
||||
@@ -387,7 +397,8 @@ export async function resumenMiDia(
|
||||
estado: a.estado,
|
||||
fechaLimite: a.fechaLimite,
|
||||
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
|
||||
@@ -874,9 +885,9 @@ export function resumenMiDiaLocal(
|
||||
.map((c) => ({
|
||||
id: c.leadId,
|
||||
nombre: c.nombreLead,
|
||||
unidadCodigo: '',
|
||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||
estado: c.etapa,
|
||||
fechaLimite: '',
|
||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||
ultimoCambio: null,
|
||||
pendienteSync: c.pendienteSync,
|
||||
})),
|
||||
|
||||
@@ -1672,6 +1672,8 @@ export interface DatosOportunidadAfluencia {
|
||||
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
|
||||
calificaLabel?: string | undefined // dropdown 'Lead Califica'
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
@@ -75,12 +75,14 @@ describe('construirMiTablero', () => {
|
||||
captura({ board: 'OPORTUNIDADES' }),
|
||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
||||
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
||||
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
||||
]
|
||||
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.apartados).toBe(1) // CANCELADO no cuenta
|
||||
expect(tablero.conversion.vendidos).toBe(1)
|
||||
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
||||
})
|
||||
|
||||
it('calcula ventas del mes correctamente', () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface MiTablero {
|
||||
oportunidades: number
|
||||
apartados: number
|
||||
vendidos: number
|
||||
/** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */
|
||||
descartados: number
|
||||
}
|
||||
ventasMes: {
|
||||
monto: number
|
||||
@@ -125,6 +127,7 @@ export function construirMiTablero(
|
||||
c.board === 'APARTADO' &&
|
||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
||||
).length
|
||||
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
||||
|
||||
// -- Ventas del mes --
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
@@ -183,7 +186,7 @@ export function construirMiTablero(
|
||||
return {
|
||||
generadoEn: ahora.toISOString(),
|
||||
leadsPorDia,
|
||||
conversion: { leads, oportunidades, apartados, vendidos },
|
||||
conversion: { leads, oportunidades, apartados, vendidos, descartados },
|
||||
ventasMes,
|
||||
porOrigen,
|
||||
porDesarrollo,
|
||||
@@ -203,6 +206,7 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
|
||||
const capturas = await prisma.leadCaptura.findMany({
|
||||
where: { usuarioId },
|
||||
select: {
|
||||
leadId: true,
|
||||
board: true,
|
||||
etapa: true,
|
||||
origen: true,
|
||||
@@ -246,11 +250,11 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
|
||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||
|
||||
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
|
||||
const whereUsuario = usuarioId ? { usuarioId } : undefined
|
||||
const usuarioFiltro = usuarioId ?? undefined
|
||||
|
||||
const [capturas, actividades, usuarios] = await Promise.all([
|
||||
prisma.leadCaptura.findMany({
|
||||
where: whereUsuario,
|
||||
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
|
||||
select: {
|
||||
usuarioId: true,
|
||||
etapa: true,
|
||||
@@ -258,14 +262,14 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
|
||||
},
|
||||
}),
|
||||
prisma.actividadLocal.findMany({
|
||||
where: whereUsuario,
|
||||
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
|
||||
select: {
|
||||
usuarioId: true,
|
||||
estado: true,
|
||||
},
|
||||
}),
|
||||
prisma.usuario.findMany({
|
||||
where: whereUsuario ? { id: usuarioId } : { activo: true },
|
||||
where: usuarioFiltro ? { id: usuarioFiltro } : { activo: true },
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
|
||||
@@ -168,23 +168,38 @@ function inicioDelDia(d: Date): Date {
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `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 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 =>
|
||||
datos.leads.filter((l) => {
|
||||
leadsRango.filter((l) => {
|
||||
const t = Date.parse(l.createdAt)
|
||||
return !Number.isNaN(t) && t >= desde.getTime()
|
||||
}).length
|
||||
|
||||
const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa))
|
||||
const totalOps = datos.oportunidades.length
|
||||
const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
|
||||
const apartadosOMas = datos.oportunidades.filter((o) =>
|
||||
const porEtapa = contarPor(oportunidadesRango.map((o) => o.etapa))
|
||||
const totalOps = oportunidadesRango.length
|
||||
const ganadas = oportunidadesRango.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
|
||||
const apartadosOMas = oportunidadesRango.filter((o) =>
|
||||
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
|
||||
).length
|
||||
const activas = datos.oportunidades.filter(
|
||||
const activas = oportunidadesRango.filter(
|
||||
(o) =>
|
||||
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
|
||||
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
|
||||
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
|
||||
leads: {
|
||||
total: datos.leads.length,
|
||||
total: leadsRango.length,
|
||||
nuevosHoy: creadosEn(hoy),
|
||||
nuevosSemana: creadosEn(hace7dias),
|
||||
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
||||
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
||||
porEstado: contarPor(leadsRango.map((l) => l.estado)),
|
||||
porOrigen: contarPor(leadsRango.map((l) => l.origen)),
|
||||
perfil: {
|
||||
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
|
||||
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
|
||||
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
|
||||
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
|
||||
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
|
||||
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
|
||||
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
|
||||
porDesarrollo: contarPorNoVacios(oportunidadesRango.map((o) => o.desarrollo ?? '')),
|
||||
porModelo: contarPorNoVacios(oportunidadesRango.map((o) => o.modeloInteres ?? '')),
|
||||
porEstadoCivil: contarPorNoVacios(oportunidadesRango.map((o) => o.estadoCivil ?? '')),
|
||||
porTipoEmpleo: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEmpleo ?? '')),
|
||||
porTipoEconomia: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEconomia ?? '')),
|
||||
porCalifica: contarPorNoVacios(oportunidadesRango.map((o) => o.califica ?? '')),
|
||||
porCredito: contarPorNoVacios(oportunidadesRango.map((o) => o.credito ?? '')),
|
||||
porRangoSalarial: contarPorNoVacios(
|
||||
datos.oportunidades
|
||||
oportunidadesRango
|
||||
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
|
||||
.filter((b): b is string => b !== null),
|
||||
),
|
||||
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
|
||||
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
|
||||
porMedioContacto: contarPorNoVacios(oportunidadesRango.map((o) => o.medioContacto ?? '')),
|
||||
porLoQuierePara: contarPorNoVacios(oportunidadesRango.map((o) => o.loQuierePara ?? '')),
|
||||
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 salud: Pick<SaludMondayService, 'estaDisponible'>
|
||||
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(
|
||||
private readonly monday: MondayReportes,
|
||||
@@ -406,15 +422,18 @@ export class ReportesService {
|
||||
|
||||
/** Invalida el cache (útil tras escrituras que cambian los números) */
|
||||
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()
|
||||
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)
|
||||
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
|
||||
const resumen = await this.cargarResumen(ahoraMs, rango)
|
||||
this.cache.set(clave, { resumen, expira: ahoraMs + this.cacheMs })
|
||||
return resumen
|
||||
}
|
||||
|
||||
@@ -423,7 +442,7 @@ export class ReportesService {
|
||||
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
|
||||
* 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()) {
|
||||
try {
|
||||
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
||||
@@ -434,7 +453,7 @@ export class ReportesService {
|
||||
this.monday.getActividadesAbiertas(),
|
||||
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) {
|
||||
if (!(err instanceof AppError && err.status === 502)) throw err
|
||||
// Monday se cayó a media lectura: cae al camino local
|
||||
@@ -444,6 +463,7 @@ export class ReportesService {
|
||||
const resumen = construirResumen(
|
||||
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
|
||||
new Date(ahoraMs),
|
||||
dias,
|
||||
)
|
||||
// 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ó
|
||||
|
||||
@@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import Burbuja from '../components/Burbuja'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
import toast from "react-hot-toast"
|
||||
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
|
||||
import {
|
||||
enviarMensaje,
|
||||
esErrorDeRed,
|
||||
listarConversaciones,
|
||||
obtenerHistorial,
|
||||
type ConversacionResumen,
|
||||
} from '../services/api'
|
||||
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
||||
import type { Mensaje } from '../types'
|
||||
|
||||
@@ -23,6 +29,12 @@ export default function ChatPage() {
|
||||
const [escribiendo, setEscribiendo] = useState(false)
|
||||
const [sinConexion, setSinConexion] = useState(false)
|
||||
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)
|
||||
|
||||
// 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' })
|
||||
}, [mensajes, escribiendo])
|
||||
|
||||
async function enviar() {
|
||||
const texto = entrada.trim()
|
||||
if (!texto || escribiendo) return
|
||||
setEntrada('')
|
||||
setError(null)
|
||||
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
|
||||
/** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */
|
||||
async function transmitir(texto: string) {
|
||||
setEscribiendo(true)
|
||||
try {
|
||||
const r = await enviarMensaje(sessionId, texto)
|
||||
setSessionId(r.sessionId)
|
||||
guardarSesion(r.sessionId)
|
||||
setSinConexion(false)
|
||||
setPendiente(null)
|
||||
setMensajes((prev) => [
|
||||
...prev,
|
||||
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
|
||||
@@ -63,18 +72,63 @@ export default function ChatPage() {
|
||||
} else {
|
||||
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 {
|
||||
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() {
|
||||
limpiarSesion()
|
||||
setSessionId(null)
|
||||
setMensajes([])
|
||||
setError(null)
|
||||
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 (
|
||||
@@ -96,6 +150,14 @@ export default function ChatPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
type="button"
|
||||
onClick={nuevaConversacion}
|
||||
@@ -163,6 +225,20 @@ export default function ChatPage() {
|
||||
</p>
|
||||
</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
|
||||
className="flex items-stretch gap-3 py-4"
|
||||
onSubmit={(e) => {
|
||||
@@ -192,6 +268,64 @@ export default function ChatPage() {
|
||||
</form>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,3 +73,14 @@ export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TipoCredito,
|
||||
SlotDocumento,
|
||||
} from '../types'
|
||||
import { getSesion } from '../services/auth'
|
||||
|
||||
export const PROYECTOS = [
|
||||
'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.
|
||||
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'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { Referencia } from '../types'
|
||||
import { referenciasSchema } from '../lib/schemas'
|
||||
@@ -11,13 +11,18 @@ import type { PasoProps } from './DatosGeneralesForm'
|
||||
// RHF trabaja con objetos raíz; el array va envuelto
|
||||
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[]>) {
|
||||
const metodos = useForm<{ referencias: Referencia[] }>({
|
||||
resolver: zodResolver(schemaFormulario),
|
||||
defaultValues: { referencias: valores },
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
const { fields, append, remove } = useFieldArray({ control: metodos.control, name: 'referencias' })
|
||||
const errorRaiz = metodos.formState.errors.referencias
|
||||
|
||||
return (
|
||||
@@ -30,9 +35,24 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
|
||||
titulo="Referencias personales"
|
||||
descripcion="Captura al menos 1 referencia con nombre y teléfono."
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} 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>
|
||||
{fields.map((field, i) => (
|
||||
<div key={field.id} className="rounded-xl border border-[#E5E7EB] p-6">
|
||||
<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">
|
||||
<CampoTexto nombre={`referencias.${i}.nombre`} etiqueta="Nombre completo" />
|
||||
<CampoTexto nombre={`referencias.${i}.telefono`} etiqueta="Teléfono" />
|
||||
@@ -41,6 +61,17 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
|
||||
</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>
|
||||
{errorRaiz && typeof errorRaiz.message === 'string' && (
|
||||
<p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { AltaCliente } from '../types'
|
||||
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'
|
||||
|
||||
const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({
|
||||
@@ -56,10 +56,18 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
|
||||
dependientes: { personas: '', parentesco: '', edad: '' },
|
||||
})
|
||||
|
||||
const referenciaVacia = (): AltaCliente['referencias'][number] => ({
|
||||
nombre: '',
|
||||
telefono: '',
|
||||
parentesco: '',
|
||||
direccion: '',
|
||||
})
|
||||
|
||||
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||
leadMondayId: null,
|
||||
apartadoMondayId: null,
|
||||
progresoId: null,
|
||||
foto: null,
|
||||
datosGenerales: datosGeneralesVacios(),
|
||||
empleo: empleoVacio(),
|
||||
economicos: economicosVacios(),
|
||||
@@ -69,12 +77,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||
empleo: empleoVacio(),
|
||||
economicos: economicosVacios(),
|
||||
},
|
||||
referencias: [
|
||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
||||
],
|
||||
referencias: [referenciaVacia()],
|
||||
viviendaCredito: {
|
||||
proyecto: '',
|
||||
proyectoOtro: '',
|
||||
@@ -107,7 +110,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||
pais: 'MEXICO',
|
||||
},
|
||||
},
|
||||
firma: { firmaDataUrl: '', asesorNombre: ASESOR_DEFAULT },
|
||||
firma: { firmaDataUrl: '', asesorNombre: asesorDeSesion() },
|
||||
})
|
||||
|
||||
export const PASOS = [
|
||||
|
||||
@@ -12,6 +12,7 @@ export const fixtureAlta: AltaCliente = {
|
||||
leadMondayId: null,
|
||||
apartadoMondayId: null,
|
||||
progresoId: null,
|
||||
foto: null,
|
||||
datosGenerales: {
|
||||
campaniaVenta: '',
|
||||
fecha: '2026-07-19',
|
||||
|
||||
@@ -170,4 +170,17 @@ describe('referenciasSchema', () => {
|
||||
])
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -149,7 +149,8 @@ const referenciaSchema = z.object({
|
||||
|
||||
export const referenciasSchema = z
|
||||
.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(
|
||||
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
|
||||
'Captura al menos 1 referencia con nombre y teléfono',
|
||||
|
||||
@@ -146,6 +146,11 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
|
||||
),
|
||||
).toHaveLength(0)
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -226,9 +226,9 @@ export default function AltaClientePage() {
|
||||
setPantalla('wizard')
|
||||
}
|
||||
|
||||
const guardar = async () => {
|
||||
const guardar = async (pasoDestino: number = paso) => {
|
||||
try {
|
||||
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
|
||||
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos })
|
||||
toast.success("Borrador guardado")
|
||||
setBorradorId(b.id)
|
||||
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) ----------
|
||||
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
|
||||
// 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
|
||||
useEffect(() => {
|
||||
if (!registro) return
|
||||
const timer = setTimeout(() => {
|
||||
// Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
|
||||
const registrarOtro = () => {
|
||||
setRegistro('')
|
||||
setSinConexion(false)
|
||||
setErrorValidacion('')
|
||||
setLigaUrl('')
|
||||
setLigaCopiada(false)
|
||||
setErrorLiga('')
|
||||
empezarNuevo()
|
||||
}
|
||||
|
||||
const irAMiDia = () => {
|
||||
window.location.href = '/hoy/'
|
||||
}, 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [registro])
|
||||
}
|
||||
|
||||
// Genera la liga pública y copia al portapapeles el texto listo para WhatsApp
|
||||
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 (
|
||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
<AppHeader
|
||||
@@ -515,10 +635,10 @@ export default function AltaClientePage() {
|
||||
<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="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 className="w-full max-w-3xl">
|
||||
<div className="w-full max-w-3xl" onInput={marcarSucio} onChange={marcarSucio}>
|
||||
{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">
|
||||
{errorCarga}
|
||||
@@ -549,9 +669,10 @@ export default function AltaClientePage() {
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<FotoCliente
|
||||
foto={null}
|
||||
onFoto={() => {
|
||||
// Foto capturada (el componente gestiona el preview internamente)
|
||||
foto={datos.foto}
|
||||
onFoto={(foto) => {
|
||||
actualizar('foto', foto || null)
|
||||
marcarSucio()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -662,16 +783,6 @@ export default function AltaClientePage() {
|
||||
)}
|
||||
{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 && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -688,35 +799,6 @@ export default function AltaClientePage() {
|
||||
El servidor rechazó el alta: {errorValidacion}
|
||||
</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>
|
||||
<FirmasForm
|
||||
alta={alta}
|
||||
@@ -729,6 +811,44 @@ export default function AltaClientePage() {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,6 +157,9 @@ export interface AltaCliente {
|
||||
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
|
||||
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
||||
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
|
||||
empleo: DatosEmpleo
|
||||
economicos: DatosEconomicos
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { request } from '../services/api'
|
||||
import { formatoMoneda } from '../lib/formatters'
|
||||
import { telefonoParaEnlace } from '../lib/cobranza'
|
||||
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 {
|
||||
clienteId: string
|
||||
clienteNombre: string
|
||||
clienteTelefono: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
pagoVencido: number // ISO fecha
|
||||
pagoVencido: number // número de pago vencido
|
||||
monto: number
|
||||
diasAtraso: number
|
||||
telefono: string | null
|
||||
planId: string
|
||||
}
|
||||
|
||||
interface MoraResponse {
|
||||
totalMorosos: number
|
||||
clientesConMora: number
|
||||
montoTotalVencido: number
|
||||
diasPromedio: number
|
||||
clientes: ClienteMoria[]
|
||||
diasPromedioAtraso: number
|
||||
clientes: ClienteMora[]
|
||||
}
|
||||
|
||||
/** Normaliza la respuesta del API; acepta tanto el shape del API como el local */
|
||||
type ClienteMoria = ClienteMora
|
||||
|
||||
function abrirWhatsApp(telefono: string) {
|
||||
const limpio = telefono.replace(/[^0-9+]/g, '')
|
||||
window.open(`https://wa.me/${limpio.startsWith('+') ? limpio.slice(1) : limpio}`, '_blank')
|
||||
// Misma normalización +52 que mi-dia: 10 dígitos → México
|
||||
window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
|
||||
}
|
||||
|
||||
export default function MoraDashboard() {
|
||||
const navigate = useNavigate()
|
||||
const [datos, setDatos] = useState<MoraResponse | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -80,9 +80,9 @@ export default function MoraDashboard() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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="Días promedio atraso" valor={datos.diasPromedio} />
|
||||
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<tbody className="divide-y divide-[#E5E7EB]">
|
||||
{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 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
|
||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
|
||||
@@ -114,15 +114,25 @@ export default function MoraDashboard() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{c.clienteTelefono && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => abrirWhatsApp(c.clienteTelefono)}
|
||||
onClick={() => navigate(`/planes/${c.planId}`)}
|
||||
className="text-xs font-medium text-accent hover:underline"
|
||||
aria-label={`Ver plan de ${c.clienteNombre}`}
|
||||
>
|
||||
Ver plan →
|
||||
</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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -183,6 +183,16 @@ export interface CobranzaItem {
|
||||
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:
|
||||
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
||||
|
||||
@@ -370,6 +370,7 @@ export default function DashboardPage() {
|
||||
<thead>
|
||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||
<th scope="col" className="px-4 py-3">Teléfono</th>
|
||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||
<th scope="col" className="px-4 py-3">Proyecto</th>
|
||||
{encabezadoOrdenable('avance', '% pagado')}
|
||||
@@ -387,6 +388,7 @@ export default function DashboardPage() {
|
||||
{d.plan.clienteNombre}
|
||||
</Link>
|
||||
</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.proyecto}</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -414,7 +416,7 @@ export default function DashboardPage() {
|
||||
))}
|
||||
{ordenados.length === 0 && (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
diasAtraso,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
proximoVencimiento,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
@@ -136,8 +137,18 @@ export default function PlanDetallePage() {
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
@@ -153,6 +164,17 @@ export default function PlanDetallePage() {
|
||||
{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>
|
||||
|
||||
{offline && (
|
||||
<p className="text-xs text-amber-700">
|
||||
@@ -245,7 +267,7 @@ export default function PlanDetallePage() {
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] 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">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
type StorageLike,
|
||||
} from './repository'
|
||||
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'
|
||||
|
||||
/** 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).not.toHaveProperty('saldo')
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://192.168.10.123:3000/api/planes',
|
||||
`${API_URL}/planes`,
|
||||
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(storage.getItem(CLAVE_STORAGE)).toBeNull()
|
||||
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')
|
||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
expect(body.clienteNombre).toBe('Cliente Prueba')
|
||||
@@ -239,7 +242,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
|
||||
|
||||
expect(r.origen).toBe('api')
|
||||
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>
|
||||
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',
|
||||
})
|
||||
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 () => {
|
||||
@@ -317,7 +320,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
|
||||
expect(r.origen).toBe('api')
|
||||
expect(r.plan.status).toBe('CANCELADO')
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -361,7 +364,7 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
|
||||
|
||||
expect(r.origen).toBe('api')
|
||||
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' } }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -107,12 +107,15 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
||||
>
|
||||
Retomar
|
||||
</button>
|
||||
{/* El alta prellenada requiere la cotización en el servidor; las locales (CTE-LOCAL-*) no la tienen */}
|
||||
{!c.id.startsWith('local-') && (
|
||||
<a
|
||||
href={`/client/?cotizacion=${c.id}`}
|
||||
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]"
|
||||
>
|
||||
Iniciar alta
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -55,6 +55,30 @@ describe('crearDocumentoPdf', () => {
|
||||
})
|
||||
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', () => {
|
||||
|
||||
91
apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx
Normal file
91
apps/cotizador/src/pages/CotizadorPage.smoke.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -21,6 +21,7 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||||
import { calcularVigencia } from '../lib/vigencia'
|
||||
import { folioCotizacion } from '../lib/folio'
|
||||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||||
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
|
||||
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import {
|
||||
getMisCotizaciones,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
TIPO_DE_API,
|
||||
type CotizacionGuardada,
|
||||
} from '../services/api'
|
||||
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
|
||||
import type { Cliente } from '../types'
|
||||
|
||||
const esquemaCliente = z.object({
|
||||
@@ -87,6 +89,8 @@ export default function CotizadorPage() {
|
||||
const [errorMias, setErrorMias] = useState<string | null>(null)
|
||||
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
|
||||
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 [aviso, setAviso] = useState<string | null>(null)
|
||||
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
|
||||
@@ -96,9 +100,22 @@ export default function CotizadorPage() {
|
||||
|
||||
useEffect(() => {
|
||||
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()
|
||||
.then((lista) => {
|
||||
if (vivo) setMisCotizaciones(lista)
|
||||
if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev))
|
||||
})
|
||||
.catch((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
|
||||
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
|
||||
|
||||
/** 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> => {
|
||||
if (!unidad || !propuestaActiva) return null
|
||||
try {
|
||||
@@ -186,9 +257,14 @@ export default function CotizadorPage() {
|
||||
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
|
||||
return conFolio
|
||||
} 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)
|
||||
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).')
|
||||
return null
|
||||
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
|
||||
const local = cotizacionLocal(cliente)
|
||||
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,11 +277,14 @@ export default function CotizadorPage() {
|
||||
let qrDataUrl: string | undefined
|
||||
if (guardada) {
|
||||
folio = guardada.folio ?? folioCotizacion(guardada.id)
|
||||
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
|
||||
if (!esCotizacionLocal(guardada)) {
|
||||
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
}
|
||||
}
|
||||
generarPdf({
|
||||
unidad,
|
||||
acabados,
|
||||
@@ -227,6 +306,7 @@ export default function CotizadorPage() {
|
||||
if (!unidad) return
|
||||
// Sin nombre capturado, pide los datos del cliente en el modal
|
||||
if (nombreCliente.trim().length < 3) {
|
||||
setGuardarSinPdf(false)
|
||||
setModalClienteAbierto(true)
|
||||
return
|
||||
}
|
||||
@@ -237,13 +317,43 @@ 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) => {
|
||||
setModalClienteAbierto(false)
|
||||
setValue('nombre', cliente.nombre, { shouldValidate: true })
|
||||
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
|
||||
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
|
||||
if (guardarSinPdf) {
|
||||
setGuardarSinPdf(false)
|
||||
void guardarSolo(cliente)
|
||||
} else {
|
||||
void generarPdfConCliente(cliente)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompartir = async () => {
|
||||
if (!unidad || !propuestaActiva) return
|
||||
@@ -256,7 +366,10 @@ export default function CotizadorPage() {
|
||||
...(ultimaGuardada
|
||||
? {
|
||||
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 idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
|
||||
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
|
||||
restaurar({
|
||||
desarrollo: c.desarrollo,
|
||||
unidad: u,
|
||||
@@ -306,7 +420,7 @@ export default function CotizadorPage() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
@@ -599,49 +713,6 @@ export default function CotizadorPage() {
|
||||
propuestas={propuestas}
|
||||
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>
|
||||
)}
|
||||
</section>
|
||||
@@ -664,6 +735,71 @@ export default function CotizadorPage() {
|
||||
</div>
|
||||
</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
|
||||
abierto={modalClienteAbierto}
|
||||
nombreInicial={watch('nombre')}
|
||||
|
||||
@@ -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[]) {
|
||||
try {
|
||||
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> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return db.get('unidades', 'actual')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function guardarCotizaciones(cotizaciones: unknown[]) {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put('cotizaciones', cotizaciones, 'historial')
|
||||
} catch {
|
||||
// Sin almacenamiento
|
||||
}
|
||||
}
|
||||
|
||||
export async function cargarCotizacionesLocal() {
|
||||
export async function cargarCotizacionesLocal(): Promise<unknown> {
|
||||
try {
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
58
apps/dashboards/package-lock.json
generated
58
apps/dashboards/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2128,6 +2129,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
@@ -3271,6 +3285,44 @@
|
||||
"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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
@@ -3424,6 +3476,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
||||
return (
|
||||
<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">
|
||||
const CLASE_BASE =
|
||||
'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="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>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,26 +3,28 @@ import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } fr
|
||||
import { tooltipProps } from '../lib/chart-theme'
|
||||
import KpiCard from './KpiCard'
|
||||
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
|
||||
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
|
||||
import { fetchResumenMiDia } from '../services/mi-dia'
|
||||
import { funnelMiTablero, type MiTablero } from '../lib/mi-tablero'
|
||||
import { fetchMiTablero } from '../services/reportes'
|
||||
|
||||
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros)
|
||||
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
|
||||
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros);
|
||||
// rojo para la etapa final "Descartados"
|
||||
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB', '#EF4444']
|
||||
|
||||
/**
|
||||
* "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades,
|
||||
* apartados por vencer) y un funnel simple de sus leads por estado.
|
||||
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
|
||||
* "Mi tablero": KPIs personales del vendedor logueado y su funnel de conversión
|
||||
* (Leads → Oportunidades → Apartados → Vendidos → Descartados).
|
||||
* 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 }) {
|
||||
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
||||
const [tablero, setTablero] = useState<MiTablero | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
try {
|
||||
setResumen(await fetchResumenMiDia())
|
||||
setTablero(await fetchMiTablero())
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
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, 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 (
|
||||
<main className="px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<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]">
|
||||
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
||||
Actualizado: {new Date(tablero.generadoEn).toLocaleString('es-MX')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -59,39 +66,43 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
|
||||
</p>
|
||||
)}
|
||||
|
||||
{resumen?.sinMapeoMonday && (
|
||||
<p className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
||||
Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías
|
||||
</p>
|
||||
)}
|
||||
{!tablero && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
|
||||
{!tablero && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
|
||||
|
||||
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
|
||||
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
|
||||
|
||||
{resumen && (
|
||||
{tablero && conversion && (
|
||||
<>
|
||||
<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
|
||||
titulo="Mis leads activos"
|
||||
valor={resumen.misLeads.length}
|
||||
detalle={`${resumen.conteos.leadsNuevosSemana} nuevo(s) esta semana`}
|
||||
titulo="Leads totales"
|
||||
valor={conversion.leads}
|
||||
detalle={`${conversion.oportunidades} oportunidades · ${conversion.vendidos} vendidos`}
|
||||
/>
|
||||
<KpiCard
|
||||
titulo="Mis actividades abiertas"
|
||||
valor={resumen.conteos.actividadesAbiertas}
|
||||
detalle={`${resumen.misActividadesHoy.length} con fecha de hoy o antes`}
|
||||
titulo="Tasa de conversión"
|
||||
valor={tasaConversion}
|
||||
detalle={`${conversion.oportunidades} de ${conversion.leads} leads`}
|
||||
/>
|
||||
<KpiCard
|
||||
titulo="Apartados por vencer"
|
||||
valor={resumen.apartadosPorVencer.length}
|
||||
detalle="próximos 7 días (pipeline completo)"
|
||||
titulo="Ventas del mes"
|
||||
valor={conversion.vendidos}
|
||||
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 aria-label="Mis leads por estado" className="mt-10">
|
||||
<TarjetaGrafica titulo="Mis leads por estado">
|
||||
{funnel.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin leads asignados" />
|
||||
<section aria-label="Funnel de conversión" className="mt-10">
|
||||
<TarjetaGrafica titulo="Funnel de conversión (incluye descartados)">
|
||||
{funnel.every((e) => e.valor === 0) ? (
|
||||
<EstadoVacio mensaje="Sin movimiento comercial" />
|
||||
) : (
|
||||
<div className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -43,10 +44,28 @@ const COLORES_STATUS: Record<string, string> = {
|
||||
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
|
||||
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,
|
||||
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
|
||||
* 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({
|
||||
mostrarEquipo,
|
||||
@@ -58,23 +77,39 @@ export default function TableroGeneral({
|
||||
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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 () => {
|
||||
setCargando(true)
|
||||
try {
|
||||
setResumen(await fetchResumen())
|
||||
setResumen(await fetchResumen(dias))
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [])
|
||||
}, [dias])
|
||||
|
||||
useEffect(() => {
|
||||
void 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
|
||||
useEffect(() => {
|
||||
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
|
||||
@@ -89,7 +124,8 @@ export default function TableroGeneral({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
|
||||
{resumen && (
|
||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||
@@ -97,6 +133,25 @@ export default function TableroGeneral({
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
||||
@@ -114,6 +169,7 @@ export default function TableroGeneral({
|
||||
titulo="Leads totales"
|
||||
valor={resumen.leads.total}
|
||||
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
|
||||
onClick={() => irAApp(RUTA_MI_DIA)}
|
||||
/>
|
||||
<KpiCard
|
||||
titulo="Oportunidades activas"
|
||||
@@ -132,7 +188,8 @@ export default function TableroGeneral({
|
||||
<KpiCard
|
||||
titulo="Cartera vencida"
|
||||
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
|
||||
titulo="Planes activos"
|
||||
|
||||
@@ -79,3 +79,40 @@ export function funnelMisLeads(misLeads: LeadMiDia[]): EtapaFunnelPersonal[] {
|
||||
.map(([etapa, valor]) => ({ etapa, valor }))
|
||||
.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 },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
@@ -11,9 +12,13 @@ import DashboardPage from './pages/DashboardPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
{/* La app vive bajo el prefijo /dash/ de NGINX (vite base); el rango de
|
||||
fechas del tablero general se persiste en la URL (?dias=7|30|90) */}
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<AuthGate>
|
||||
<DashboardPage />
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
// Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX)
|
||||
import { conAuth, manejar401 } from './auth'
|
||||
import type { ResumenReportes } from '../lib/reportes'
|
||||
import type { MiTablero } from '../lib/mi-tablero'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
/** GET /reportes/resumen; lanza Error con mensaje legible si el API falla */
|
||||
export async function fetchResumen(): Promise<ResumenReportes> {
|
||||
async function obtener<T>(path: string): Promise<T> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`${API_URL}/reportes/resumen`, conAuth())
|
||||
res = await fetch(`${API_URL}${path}`, conAuth())
|
||||
} catch {
|
||||
throw new Error('No se pudo conectar con el servidor')
|
||||
}
|
||||
manejar401(res)
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 =>
|
||||
iso
|
||||
@@ -47,7 +47,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
|
||||
return () => window.removeEventListener('keydown', alTeclear)
|
||||
}, [onCerrar])
|
||||
|
||||
const c = detalle?.contacto ?? {}
|
||||
const c: Partial<DatosContacto> = detalle?.contacto ?? {}
|
||||
const prop = detalle?.propiedad ?? null
|
||||
const hist = detalle?.historial ?? []
|
||||
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>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -83,15 +83,15 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
|
||||
<div className="mt-5 space-y-5">
|
||||
{/* Datos personales */}
|
||||
<Section titulo="Datos personales">
|
||||
<Field label="Email" value={c['Email']} />
|
||||
<Field label="Teléfono" value={c['Teléfono']} />
|
||||
<Field label="Estado" value={c['Estado']} />
|
||||
<Field label="CURP" value={c['CURP']} />
|
||||
<Field label="RFC" value={c['RFC']} />
|
||||
<Field label="Estado civil" value={c['Estado Civil']} />
|
||||
<Field label="Fecha nacimiento" value={c['Fecha Nacimiento']} />
|
||||
<Field label="Actividad económica" value={c['Actividad Economica']} />
|
||||
<Field label="Dirección" value={c['Dirección']} />
|
||||
<Field label="Email" value={c.email} />
|
||||
<Field label="Teléfono" value={c.telefono} />
|
||||
<Field label="Estado" value={c.estado} />
|
||||
<Field label="CURP" value={c.curp} />
|
||||
<Field label="RFC" value={c.rfc} />
|
||||
<Field label="Estado civil" value={c.estadoCivil} />
|
||||
<Field label="Fecha nacimiento" value={c.fechaNacimiento} />
|
||||
<Field label="Actividad económica" value={c.actividadEconomica} />
|
||||
<Field label="Dirección" value={c.direccion} />
|
||||
</Section>
|
||||
|
||||
{/* Propiedad */}
|
||||
|
||||
@@ -45,15 +45,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
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={() => {}} />)
|
||||
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: 'Usuarios' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy()
|
||||
// El tab de "Modo de trabajo" está activo por defecto
|
||||
expect(screen.getByText('Modo de trabajo')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Modo de trabajo' })).toBeNull()
|
||||
// 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 () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { ModoTrabajo } from './ModoTrabajo'
|
||||
import { RolesPermisos } from './RolesPermisos'
|
||||
import { Usuarios } from './Usuarios'
|
||||
import { Sistema } from './Sistema'
|
||||
@@ -10,17 +9,16 @@ interface Props {
|
||||
onVolver: () => void
|
||||
}
|
||||
|
||||
type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema' | 'auditoria'
|
||||
type Tab = 'roles' | 'usuarios' | 'sistema' | 'auditoria'
|
||||
|
||||
const TABS_BASE: { key: Tab; label: string }[] = [
|
||||
{ key: 'modo', label: 'Modo de trabajo' },
|
||||
{ key: 'roles', label: 'Roles y permisos' },
|
||||
{ key: 'usuarios', label: 'Usuarios' },
|
||||
{ key: 'sistema', label: 'Sistema' },
|
||||
]
|
||||
|
||||
export function ConfigPage({ onVolver }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('modo')
|
||||
const [tab, setTab] = useState<Tab>('roles')
|
||||
const esAdmin = getSesion()?.usuario.rol === 'admin'
|
||||
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 */}
|
||||
<section className="mt-6">
|
||||
{tab === 'modo' && <ModoTrabajo />}
|
||||
{tab === 'roles' && <RolesPermisos />}
|
||||
{tab === 'usuarios' && <Usuarios />}
|
||||
{tab === 'sistema' && <Sistema />}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
|
||||
}
|
||||
|
||||
describe('MODULOS', () => {
|
||||
it('expone los 8 módulos del ecosistema', () => {
|
||||
expect(MODULOS).toHaveLength(8)
|
||||
it('expone los 9 módulos del ecosistema', () => {
|
||||
expect(MODULOS).toHaveLength(9)
|
||||
expect(MODULOS.map((m) => m.id)).toEqual([
|
||||
'cotizador',
|
||||
'alta-clientes',
|
||||
@@ -19,6 +19,7 @@ describe('MODULOS', () => {
|
||||
'mi-dia',
|
||||
'contactos',
|
||||
'captura',
|
||||
'clientes-maestro',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
|
||||
import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
|
||||
import ModalClienteMaestro from '../components/ModalClienteMaestro'
|
||||
import { getSesion } from '../services/auth'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
interface Props {
|
||||
@@ -16,7 +15,6 @@ const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCE
|
||||
/**
|
||||
* Página / clientes-maestro: tabla paginada con filtros de búsqueda,
|
||||
* 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) {
|
||||
const [datos, setDatos] = useState<ClienteMaestro[]>([])
|
||||
@@ -29,9 +27,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
|
||||
const [filtroEstatus, setFiltroEstatus] = useState('')
|
||||
const [busquedaDebounced, setBusquedaDebounced] = useState('')
|
||||
|
||||
const sesion = getSesion()
|
||||
const esAdmin = sesion?.usuario.rol === 'admin'
|
||||
|
||||
// Debounce búsqueda
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Filtros */}
|
||||
|
||||
@@ -72,9 +72,24 @@ export interface EventoHistorialContacto {
|
||||
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 {
|
||||
/** Columnas del board por título ('nombre' incluido); '' si vacía */
|
||||
contacto: Record<string, string>
|
||||
/** Campos nombrados del modelo local (antes: títulos de columna de Monday) */
|
||||
contacto: DatosContacto
|
||||
propiedad: {
|
||||
id: string
|
||||
unidadCodigo: string
|
||||
|
||||
@@ -53,9 +53,9 @@ describe('DashboardVendedor', () => {
|
||||
await waitFor(() => {
|
||||
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('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('Funnel de conversión')).toBeTruthy()
|
||||
expect(screen.getByText('Rendimiento por origen')).toBeTruthy()
|
||||
|
||||
@@ -76,16 +76,14 @@ export default function DashboardVendedor() {
|
||||
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
|
||||
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 leadsMesAnterior = 0
|
||||
for (const d of tablero.leadsPorDia) {
|
||||
const fecha = new Date(d.fecha)
|
||||
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) {
|
||||
leadsMesActual += d.cantidad
|
||||
}
|
||||
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
|
||||
leadsMesAnterior += d.cantidad
|
||||
}
|
||||
const [anio, mes] = d.fecha.split('-').map(Number)
|
||||
if (anio === anioActual && mes === mesActual + 1) leadsMesActual += d.cantidad
|
||||
if (anio === anioAnterior && mes === mesAnterior + 1) leadsMesAnterior += d.cantidad
|
||||
}
|
||||
|
||||
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
||||
@@ -100,14 +98,16 @@ export default function DashboardVendedor() {
|
||||
{
|
||||
metrica: '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],
|
||||
nombreMesAnterior: nombresMes[mesAnterior],
|
||||
},
|
||||
{
|
||||
metrica: 'Ventas',
|
||||
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],
|
||||
nombreMesAnterior: nombresMes[mesAnterior],
|
||||
},
|
||||
@@ -211,6 +211,9 @@ export default function DashboardVendedor() {
|
||||
{/* 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">
|
||||
<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}>
|
||||
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
||||
|
||||
@@ -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'
|
||||
|
||||
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
||||
{ id: 'info', titulo: 'Información del cliente potencial', icono: User },
|
||||
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar },
|
||||
{ id: 'info', titulo: 'Información', icono: User },
|
||||
{ id: 'cita', titulo: 'Cita', icono: Calendar },
|
||||
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
|
||||
{ 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 esLd = esLead(item)
|
||||
const vendedor = getSesion()?.usuario.nombre ?? ''
|
||||
@@ -49,6 +58,23 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
const [busquedaNota, setBusquedaNota] = useState('')
|
||||
// --- Tareas ---
|
||||
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 () => {
|
||||
setCargandoNotas(true)
|
||||
@@ -102,8 +128,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
{/* 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>
|
||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Editar "{item.nombre}"</h2>
|
||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Añadir y editar oportunidad Detalles, Actividades, notas y Cita.</p>
|
||||
<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]">Seguimiento del cliente: información, citas, tareas y notas</p>
|
||||
</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>
|
||||
</div>
|
||||
@@ -168,14 +194,37 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
|
||||
{tab === 'cita' && (
|
||||
<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 ? (
|
||||
<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-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
|
||||
</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>
|
||||
)}
|
||||
@@ -267,7 +316,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
||||
|
||||
{tab === 'miembros' && (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import ScoreBadge from './ScoreBadge'
|
||||
|
||||
/** Columna genérica del kanban */
|
||||
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">
|
||||
<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>
|
||||
{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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -112,42 +112,29 @@ 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
|
||||
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const
|
||||
|
||||
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
||||
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
||||
|
||||
// Estados de la columna status del board Leads (vocabulario real del API:
|
||||
// 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)
|
||||
export const MOTIVOS_NO_COMPRA = [
|
||||
'Precio',
|
||||
'Producto',
|
||||
'Ubicacion',
|
||||
'Financiamiento',
|
||||
'Competencia',
|
||||
'Cliente',
|
||||
'Timing',
|
||||
] as const
|
||||
|
||||
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
|
||||
|
||||
// Subcategorías por categoría del tablero Motivos No Compra (catálogo del
|
||||
// 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[]> = {
|
||||
/**
|
||||
* VOCABULARIO canónico FRASA: única fuente de verdad de fases, estados,
|
||||
* 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'],
|
||||
@@ -155,7 +142,27 @@ export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, string[]> = {
|
||||
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
|
||||
|
||||
// Constantes derivadas (nombres estables que consume el resto de la app)
|
||||
export const ORIGENES_LEAD = VOCABULARIO.ORIGENES
|
||||
|
||||
export const DESARROLLOS_LEAD = VOCABULARIO.DESARROLLOS
|
||||
|
||||
// Alimenta el filtro por estado de Mis leads
|
||||
export const ESTADOS_LEAD = VOCABULARIO.FASES_LEAD
|
||||
|
||||
export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA
|
||||
|
||||
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
|
||||
|
||||
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
|
||||
|
||||
// LADAs soportadas en la captura de teléfonos (México default, USA opcional)
|
||||
export const LADAS = [
|
||||
@@ -193,20 +200,11 @@ export function urlWhatsApp(nombre: string, telefono: string, vendedor: string):
|
||||
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
|
||||
}
|
||||
|
||||
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
|
||||
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
|
||||
// Fases del funnel de Oportunidades y del pipeline Enkontrol (del VOCABULARIO)
|
||||
export const FASES_OPORTUNIDAD = VOCABULARIO.FASES_OPORTUNIDAD
|
||||
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
|
||||
|
||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
||||
export const FASES_APARTADO = [
|
||||
'Pago Apartado',
|
||||
'Alta Enkontrol',
|
||||
'Entrego Documentacion',
|
||||
'Cita Clientes',
|
||||
'Contrato Enkontrol',
|
||||
'Firma Cliente',
|
||||
'Cancelacion Apartado',
|
||||
] as const
|
||||
export const FASES_APARTADO = VOCABULARIO.FASES_APARTADO
|
||||
export type FaseApartado = (typeof FASES_APARTADO)[number]
|
||||
|
||||
// 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',
|
||||
}
|
||||
|
||||
export const FASES_ESCRITURACION = [
|
||||
'Venta',
|
||||
'Seguimiento',
|
||||
'Integracion',
|
||||
'X Autorizar',
|
||||
'Autorizado',
|
||||
'X Escriturar',
|
||||
'Escriturado',
|
||||
'Titulado',
|
||||
'Cancelo',
|
||||
] as const
|
||||
export const FASES_ESCRITURACION = VOCABULARIO.FASES_ESCRITURACION
|
||||
export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number]
|
||||
|
||||
export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = {
|
||||
|
||||
@@ -51,7 +51,6 @@ import { exportarLeads, exportarOportunidades, exportarApartados } from '../serv
|
||||
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
||||
import { getSesion } from '../services/auth'
|
||||
import DashboardVendedor from '../components/DashboardVendedor'
|
||||
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
|
||||
import KanbanView from '../components/KanbanView'
|
||||
import CalendarioCitas from '../components/CalendarioCitas'
|
||||
import DetalleLeadModal from '../components/DetalleLeadModal'
|
||||
@@ -618,7 +617,6 @@ function FilaLead({
|
||||
>
|
||||
{lead.nombre}
|
||||
</button>
|
||||
<ScoreBadge score={calcularScore(lead)} />
|
||||
|
||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||
<span
|
||||
@@ -1677,6 +1675,17 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
||||
<DetalleLeadModal
|
||||
item={leadSeleccionado}
|
||||
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user