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");
|
||||||
@@ -136,14 +136,18 @@ model Pago {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model AplicacionPago {
|
model AplicacionPago {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
pagoId String
|
pagoId String
|
||||||
pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade)
|
pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade)
|
||||||
fechaPago DateTime
|
fechaPago DateTime
|
||||||
monto Float
|
monto Float
|
||||||
referencia String
|
referencia String
|
||||||
|
// Usuario que capturó el abono (sub del JWT); null en abonos históricos o
|
||||||
|
// hechos sin auth (AUTH_ENABLED=false)
|
||||||
|
capturadoPorId String?
|
||||||
|
|
||||||
@@index([pagoId])
|
@@index([pagoId])
|
||||||
|
@@index([capturadoPorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vinculación lead ↔ oportunidad del funnel: cada lead capturado entra al
|
// Vinculación lead ↔ oportunidad del funnel: cada lead capturado entra al
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { ACCIONES_POR_ROL_DEFAULT } from '../src/config/acciones.ts'
|
import { ACCIONES_POR_ROL_DEFAULT } from '../src/config/acciones.ts'
|
||||||
import { MODULOS } from '../src/config/roles.ts'
|
import { MATRIZ, MODULOS } from '../src/config/roles.ts'
|
||||||
|
|
||||||
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
|
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
|
||||||
@@ -31,19 +31,8 @@ const CONFIGS_DEFAULT = [
|
|||||||
{ clave: 'round_robin_habilitado', valor: 'false' },
|
{ clave: 'round_robin_habilitado', valor: 'false' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── RolConfiguracion: módulos por rol (matriz de config/roles.ts) ──────
|
// ── RolConfiguracion: módulos por rol (la MATRIZ de config/roles.ts es la
|
||||||
const MODULOS_VENDEDOR = [
|
// fuente de verdad; aquí solo se persiste para que /auth/me la lea de DB) ──
|
||||||
'mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat',
|
|
||||||
]
|
|
||||||
|
|
||||||
const MATRIZ_MODULOS = {
|
|
||||||
vendedor: MODULOS_VENDEDOR,
|
|
||||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
|
||||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
|
||||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
|
||||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat'],
|
|
||||||
admin: [...MODULOS],
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('⚙️ Sembrando ConfiguracionSistema...')
|
console.log('⚙️ Sembrando ConfiguracionSistema...')
|
||||||
@@ -57,7 +46,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n⚙️ Sembrando RolConfiguracion...')
|
console.log('\n⚙️ Sembrando RolConfiguracion...')
|
||||||
for (const [rol, modulos] of Object.entries(MATRIZ_MODULOS)) {
|
for (const [rol, modulos] of Object.entries(MATRIZ)) {
|
||||||
const acciones = ACCIONES_POR_ROL_DEFAULT[rol] ?? ACCIONES_POR_ROL_DEFAULT.vendedor
|
const acciones = ACCIONES_POR_ROL_DEFAULT[rol] ?? ACCIONES_POR_ROL_DEFAULT.vendedor
|
||||||
await prisma.rolConfiguracion.upsert({
|
await prisma.rolConfiguracion.upsert({
|
||||||
where: { rol },
|
where: { rol },
|
||||||
|
|||||||
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 pagina = req.query.pagina ? Number(req.query.pagina) : undefined
|
||||||
const limite = req.query.limite ? Number(req.query.limite) : undefined
|
const limite = req.query.limite ? Number(req.query.limite) : undefined
|
||||||
|
|
||||||
const resultado = await clienteMaestroService.listar({ busqueda, desarrollo, estatus, pagina, limite })
|
const resultado = await clienteMaestroService.listar({
|
||||||
|
busqueda,
|
||||||
|
desarrollo,
|
||||||
|
estatus,
|
||||||
|
...(pagina !== undefined ? { pagina } : {}),
|
||||||
|
...(limite !== undefined ? { limite } : {}),
|
||||||
|
})
|
||||||
res.json(resultado)
|
res.json(resultado)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ describe('detalleContactoEndpoint (fallback local)', () => {
|
|||||||
|
|
||||||
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
|
expect(mondayMock.getContactoDetalle).not.toHaveBeenCalled()
|
||||||
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
|
const detalle = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||||
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877' })
|
expect(detalle['contacto']).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877' })
|
||||||
expect(detalle['propiedad']).toBeNull()
|
expect(detalle['propiedad']).toBeNull()
|
||||||
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
|
expect((detalle['relaciones'] as Record<string, unknown[]>)['oportunidades']).toHaveLength(1)
|
||||||
expect(Array.isArray(detalle['historial'])).toBe(true)
|
expect(Array.isArray(detalle['historial'])).toBe(true)
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ type MondayLeads = Pick<
|
|||||||
| 'buscarLeadPorTelefono'
|
| 'buscarLeadPorTelefono'
|
||||||
| 'buscarOportunidadPorTelefono'
|
| 'buscarOportunidadPorTelefono'
|
||||||
| 'getLeads'
|
| 'getLeads'
|
||||||
|
| 'getOportunidades'
|
||||||
| 'labelsDeColumna'
|
| 'labelsDeColumna'
|
||||||
| 'obtenerNombreItem'
|
| 'obtenerNombreItem'
|
||||||
| 'columnaIdPorTitulo'
|
| 'columnaIdPorTitulo'
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
leadCaptura: {
|
leadCaptura: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
leadOportunidad: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
cliente: {
|
cliente: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -36,7 +33,6 @@ vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
|||||||
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||||
|
|
||||||
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
|
import { resumenMiDiaEndpoint } from './mi-dia.controller.js'
|
||||||
import { saludMonday } from '../services/salud-monday.service.js'
|
|
||||||
|
|
||||||
const usuarioFake = {
|
const usuarioFake = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
@@ -80,12 +76,9 @@ const reqFake = { usuarioToken: { sub: 'u-1' } } as unknown as Request
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.restoreAllMocks() // restaura los spyOn(saludMonday, 'estaDisponible')
|
|
||||||
saludMonday.restablecer()
|
|
||||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake)
|
||||||
prismaMock.usuario.findMany.mockResolvedValue([])
|
prismaMock.usuario.findMany.mockResolvedValue([])
|
||||||
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
prismaMock.leadCaptura.findMany.mockResolvedValue([])
|
||||||
prismaMock.leadOportunidad.findMany.mockResolvedValue([])
|
|
||||||
prismaMock.cliente.findMany.mockResolvedValue([])
|
prismaMock.cliente.findMany.mockResolvedValue([])
|
||||||
prismaMock.actividadLocal.findMany.mockResolvedValue([])
|
prismaMock.actividadLocal.findMany.mockResolvedValue([])
|
||||||
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
|
mondayMock.getUsuariosWorkspace.mockResolvedValue([])
|
||||||
@@ -96,9 +89,8 @@ beforeEach(() => {
|
|||||||
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
|
mondayMock.getUltimosCambios.mockResolvedValue(new Map())
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
describe('resumenMiDiaEndpoint (siempre local, Monday purgado)', () => {
|
||||||
it('Monday caído (circuit breaker): resumen 100% local, sin llamadas a Monday', async () => {
|
it('responde 100% desde LeadCaptura: Monday no se llama nunca', async () => {
|
||||||
vi.spyOn(saludMonday, 'estaDisponible').mockReturnValue(false)
|
|
||||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
|
capturaFake({ leadId: 'loc_l1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true }),
|
||||||
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
|
capturaFake({ leadId: 'loc_o1', board: 'OPORTUNIDADES', etapa: 'Afluencia', pendienteSync: true }),
|
||||||
@@ -109,15 +101,16 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
|||||||
|
|
||||||
expect(mondayMock.getLeads).not.toHaveBeenCalled()
|
expect(mondayMock.getLeads).not.toHaveBeenCalled()
|
||||||
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
|
expect(mondayMock.getOportunidades).not.toHaveBeenCalled()
|
||||||
|
expect(mondayMock.getApartados).not.toHaveBeenCalled()
|
||||||
|
expect(mondayMock.getUsuariosWorkspace).not.toHaveBeenCalled()
|
||||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||||
expect((resumen["misLeads"] as unknown[]).length).toBe(1) // solo leads, no oportunidades
|
expect((resumen['misLeads'] as unknown[]).length).toBe(1) // solo leads, no oportunidades
|
||||||
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)
|
expect((resumen['misOportunidades'] as { items: unknown[] }).items).toHaveLength(1)
|
||||||
expect(resumen['misActividadesHoy']).toEqual([])
|
expect(resumen['misActividadesHoy']).toEqual([])
|
||||||
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
|
expect(resumen['conteos']).toMatchObject({ actividadesAbiertas: 0 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Monday se cae a media lectura (502): cae al resumen local', async () => {
|
it('las capturas locales llegan con pendienteSync (badge ⏳ del front)', async () => {
|
||||||
mondayMock.getLeads.mockRejectedValue(new AppError(502, 'Monday no respondió'))
|
|
||||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
||||||
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
|
capturaFake({ leadId: 'loc_l1', board: 'LEADS', pendienteSync: true }),
|
||||||
])
|
])
|
||||||
@@ -126,41 +119,11 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
|||||||
await resumenMiDiaEndpoint(reqFake, res)
|
await resumenMiDiaEndpoint(reqFake, res)
|
||||||
|
|
||||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
||||||
expect((resumen['misLeads'] as { id: string }[]).map((l) => l.id)).toEqual(['loc_l1'])
|
expect((resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]).map((l) => l.id)).toEqual(['loc_l1'])
|
||||||
|
expect((resumen['misLeads'] as { pendienteSync?: boolean }[])[0]?.pendienteSync).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Monday vivo: unión — las capturas pendientes se agregan con pendienteSync (badge ⏳)', async () => {
|
it('sin capturas: listas vacías y sin flags de sync', async () => {
|
||||||
mondayMock.getLeads.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: '9001',
|
|
||||||
nombre: 'Lead Monday',
|
|
||||||
createdAt: '2026-09-01T10:00:00.000Z',
|
|
||||||
updatedAt: '2026-09-02T10:00:00.000Z',
|
|
||||||
estado: 'Nuevo',
|
|
||||||
origen: 'Web',
|
|
||||||
telefono: '',
|
|
||||||
email: '',
|
|
||||||
vendedorIds: [123],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
prismaMock.leadCaptura.findMany.mockResolvedValue([
|
|
||||||
capturaFake({ leadId: 'loc_n1', board: 'LEADS', etapa: 'NUEVO', pendienteSync: true, updatedAt: new Date('2026-09-05T10:00:00') }),
|
|
||||||
])
|
|
||||||
const { res, json } = resFake()
|
|
||||||
|
|
||||||
await resumenMiDiaEndpoint(reqFake, res)
|
|
||||||
|
|
||||||
const resumen = json.mock.calls[0]?.[0] as Record<string, unknown>
|
|
||||||
const leads = resumen['misLeads'] as { id: string; pendienteSync?: boolean }[]
|
|
||||||
expect(leads.map((l) => l.id)).toEqual(['loc_n1', '9001']) // el local, más reciente, primero
|
|
||||||
expect(leads[0]?.pendienteSync).toBe(true)
|
|
||||||
expect(leads[1]?.pendienteSync).toBeUndefined() // el de Monday no lleva flag
|
|
||||||
// Y la captura quedó marcada en Mis capturas
|
|
||||||
const capturas = resumen['misCapturas'] as { leadId: string; pendienteSync?: boolean; estado: string }[]
|
|
||||||
expect(capturas.find((c) => c.leadId === 'loc_n1')).toMatchObject({ pendienteSync: true, estado: 'NUEVO' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Monday vivo y sin pendientes: respuesta idéntica a la de hoy (sin flags)', async () => {
|
|
||||||
const { res, json } = resFake()
|
const { res, json } = resFake()
|
||||||
|
|
||||||
await resumenMiDiaEndpoint(reqFake, res)
|
await resumenMiDiaEndpoint(reqFake, res)
|
||||||
@@ -170,8 +133,20 @@ describe('resumenMiDiaEndpoint (modo local y unión)', () => {
|
|||||||
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
|
expect(JSON.stringify(resumen)).not.toContain('pendienteSync')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('un error no-Monday se propaga (no se traga nada)', async () => {
|
it('los usuarios del equipo solo se consultan para roles coordinador/admin/corporativo', async () => {
|
||||||
mondayMock.getLeads.mockRejectedValue(new AppError(500, 'bug'))
|
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||||
|
expect(prismaMock.usuario.findMany).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
prismaMock.usuario.findUnique.mockResolvedValue({ ...usuarioFake, rol: 'coordinador' })
|
||||||
|
await resumenMiDiaEndpoint(reqFake, resFake().res)
|
||||||
|
expect(prismaMock.usuario.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { activo: true },
|
||||||
|
select: { id: true, nombre: true, rol: true, activo: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un error de BD se propaga (no se traga nada)', async () => {
|
||||||
|
prismaMock.leadCaptura.findMany.mockRejectedValue(new AppError(500, 'bug'))
|
||||||
await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug')
|
await expect(resumenMiDiaEndpoint(reqFake, resFake().res)).rejects.toThrow('bug')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
import { mondayService } from '../services/monday.service.js'
|
import { resumenMiDiaLocal, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
|
||||||
import { resumenMiDia, resumenMiDiaLocal, unirPendientesLocales, type ActividadLocalMiDia, type UsuarioLocal } from '../services/mi-dia.service.js'
|
|
||||||
import { saludMonday } from '../services/salud-monday.service.js'
|
|
||||||
import { esFalloMonday } from '../services/sync-monday.service.js'
|
|
||||||
import { config } from '../config/index.js'
|
|
||||||
import { AppError } from '../utils/errors.js'
|
import { AppError } from '../utils/errors.js'
|
||||||
|
|
||||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/mi-dia/resumen — agenda del vendedor logueado.
|
* GET /api/mi-dia/resumen — agenda del vendedor logueado.
|
||||||
* El "mío" se resuelve por Usuario.mondayUserId (mapeo usuario↔Monday);
|
* SIEMPRE responde desde datos locales (LeadCaptura / Cliente / ActividadLocal):
|
||||||
* sin mapeo, las listas personales van vacías con sinMapeoMonday: true.
|
* Monday ya no participa en este endpoint. El "mío" se resuelve por el
|
||||||
|
* usuarioId de cada captura; admin/dirección ven todas.
|
||||||
*/
|
*/
|
||||||
export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise<void> {
|
export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
// Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin
|
// Con AUTH_ENABLED=false (arranque temprano/tests) no hay token: se usa el admin
|
||||||
@@ -22,27 +19,6 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
|||||||
: await prisma.usuario.findUnique({ where: { username: 'admin' } })
|
: await prisma.usuario.findUnique({ where: { username: 'admin' } })
|
||||||
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
||||||
|
|
||||||
// Nombres de los usuarios ya mapeados a Monday (para la sección equipo)
|
|
||||||
const mapeados = await prisma.usuario.findMany({
|
|
||||||
where: { mondayUserId: { not: null }, activo: true },
|
|
||||||
select: { nombre: true, mondayUserId: true },
|
|
||||||
})
|
|
||||||
const nombresPorMondayId = new Map<number, string>(
|
|
||||||
mapeados.filter((m) => m.mondayUserId !== null).map((m) => [m.mondayUserId!, m.nombre]),
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODOS los usuarios del workspace de Monday (el "último cambio" de un lead
|
|
||||||
// puede ser de alguien sin cuenta en el ecosistema); si ya estaba mapeado
|
|
||||||
// gana el nombre del ecosistema. Si Monday falla, el resumen sale igual.
|
|
||||||
try {
|
|
||||||
for (const u of await mondayService.getUsuariosWorkspace()) {
|
|
||||||
if (!nombresPorMondayId.has(u.id)) nombresPorMondayId.set(u.id, u.nombre)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error('No se pudieron listar los usuarios del workspace de Monday:', err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capturas de afluencia: admin ve todas, otros solo las suyas
|
// Capturas de afluencia: admin ve todas, otros solo las suyas
|
||||||
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
|
const esAdmin = usuario.rol === 'admin' || usuario.rol === 'direccion_general' || usuario.rol === 'director_ventas'
|
||||||
const capturas = await prisma.leadCaptura.findMany({
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
@@ -50,92 +26,40 @@ export async function resumenMiDiaEndpoint(req: Request, res: Response): Promise
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// "Lo mío" incluye las oportunidades ligadas a mis capturas (mapping
|
|
||||||
// LeadOportunidad: conversión con "Agendo" y afluencias históricas)
|
|
||||||
const mappings = await prisma.leadOportunidad.findMany({
|
|
||||||
where: { leadId: { in: capturas.map((c) => c.leadId) } },
|
|
||||||
})
|
|
||||||
const capturadosIds = new Set([
|
|
||||||
...capturas.map((c) => c.leadId),
|
|
||||||
...capturas.map((c) => c.oportunidadId).filter((id): id is string => id !== null),
|
|
||||||
...mappings.map((m) => m.oportunidadId),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Altas de clientes: admin ve todas, otros solo las suyas
|
// Altas de clientes: admin ve todas, otros solo las suyas
|
||||||
const clientes = await prisma.cliente.findMany({
|
const clientes = await prisma.cliente.findMany({
|
||||||
where: esAdmin ? {} : { capturadoPorId: usuario.id },
|
where: esAdmin ? {} : { capturadoPorId: usuario.id },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Actividades locales del usuario
|
||||||
|
const actividadesRaw = await prisma.actividadLocal.findMany({
|
||||||
|
where: { usuarioId: usuario.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
titulo: a.titulo,
|
||||||
|
descripcion: a.descripcion,
|
||||||
|
usuarioId: a.usuarioId,
|
||||||
|
leadCapturaId: a.leadCapturaId,
|
||||||
|
fechaVencimiento: a.fechaVencimiento,
|
||||||
|
fechaCierre: a.fechaCierre,
|
||||||
|
estado: a.estado,
|
||||||
|
tipo: a.tipo,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Usuarios activos (para la sección equipo, solo coordinador/admin)
|
||||||
|
let usuarios: UsuarioLocal[] = []
|
||||||
|
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
||||||
|
const usuariosRaw = await prisma.usuario.findMany({
|
||||||
|
where: { activo: true },
|
||||||
|
select: { id: true, nombre: true, rol: true, activo: true },
|
||||||
|
})
|
||||||
|
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
||||||
|
}
|
||||||
|
|
||||||
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
|
const usuarioMiDia = { nombre: usuario.nombre, rol: usuario.rol, mondayUserId: usuario.mondayUserId }
|
||||||
|
|
||||||
// Monday caído (circuit breaker / MODO_LOCAL_FORZADO): todo desde local
|
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
|
||||||
if (!saludMonday.estaDisponible()) {
|
|
||||||
// Actividades locales del usuario
|
|
||||||
const actividadesRaw = await prisma.actividadLocal.findMany({
|
|
||||||
where: { usuarioId: usuario.id },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
|
|
||||||
id: a.id,
|
|
||||||
titulo: a.titulo,
|
|
||||||
descripcion: a.descripcion,
|
|
||||||
usuarioId: a.usuarioId,
|
|
||||||
leadCapturaId: a.leadCapturaId,
|
|
||||||
fechaVencimiento: a.fechaVencimiento,
|
|
||||||
fechaCierre: a.fechaCierre,
|
|
||||||
estado: a.estado,
|
|
||||||
tipo: a.tipo,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Usuarios activos (para la sección equipo, solo coordinador/admin)
|
|
||||||
let usuarios: UsuarioLocal[] = []
|
|
||||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
|
||||||
const usuariosRaw = await prisma.usuario.findMany({
|
|
||||||
where: { activo: true },
|
|
||||||
select: { id: true, nombre: true, rol: true, activo: true },
|
|
||||||
})
|
|
||||||
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resumen = await resumenMiDia(
|
|
||||||
mondayService,
|
|
||||||
usuarioMiDia,
|
|
||||||
nombresPorMondayId,
|
|
||||||
capturas,
|
|
||||||
clientes,
|
|
||||||
new Date(),
|
|
||||||
config.automatizaciones.vendedorCompartidoId > 0 ? config.automatizaciones.vendedorCompartidoId : null,
|
|
||||||
capturadosIds,
|
|
||||||
)
|
|
||||||
// Unión: capturas locales aún no sincronizadas se agregan con badge ⏳
|
|
||||||
res.json(unirPendientesLocales(resumen, capturas.filter((c) => c.pendienteSync), usuario.nombre))
|
|
||||||
} catch (err) {
|
|
||||||
// Monday se cayó a media lectura: mismo resumen, pero todo local
|
|
||||||
if (!esFalloMonday(err)) throw err
|
|
||||||
const actividadesRaw = await prisma.actividadLocal.findMany({
|
|
||||||
where: { usuarioId: usuario.id },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
const actividades: ActividadLocalMiDia[] = actividadesRaw.map((a) => ({
|
|
||||||
id: a.id, titulo: a.titulo, descripcion: a.descripcion,
|
|
||||||
usuarioId: a.usuarioId, leadCapturaId: a.leadCapturaId,
|
|
||||||
fechaVencimiento: a.fechaVencimiento, fechaCierre: a.fechaCierre,
|
|
||||||
estado: a.estado, tipo: a.tipo,
|
|
||||||
}))
|
|
||||||
let usuarios: UsuarioLocal[] = []
|
|
||||||
if (ROLES_CON_EQUIPO.includes(usuario.rol)) {
|
|
||||||
const usuariosRaw = await prisma.usuario.findMany({
|
|
||||||
where: { activo: true },
|
|
||||||
select: { id: true, nombre: true, rol: true, activo: true },
|
|
||||||
})
|
|
||||||
usuarios = usuariosRaw.map((u) => ({ id: u.id, nombre: u.nombre, rol: u.rol, activo: u.activo }))
|
|
||||||
}
|
|
||||||
res.json(resumenMiDiaLocal(usuarioMiDia, capturas, clientes, new Date(), actividades, usuarios))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ export async function registrarAbonoEndpoint(req: Request, res: Response): Promi
|
|||||||
fechaPago: new Date(body.fechaPago),
|
fechaPago: new Date(body.fechaPago),
|
||||||
monto: aplicacion.monto,
|
monto: aplicacion.monto,
|
||||||
referencia: aplicacion.referencia,
|
referencia: aplicacion.referencia,
|
||||||
|
// Quien capturó el abono: sub del JWT (null si AUTH_ENABLED=false o token sin sub)
|
||||||
|
capturadoPorId: req.usuarioToken?.sub ?? null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.pago.update({
|
prisma.pago.update({
|
||||||
|
|||||||
@@ -90,7 +90,12 @@ export function crearController(svc: RecordatorioVendedorService) {
|
|||||||
const usuarioId = req.usuarioToken?.sub
|
const usuarioId = req.usuarioToken?.sub
|
||||||
if (!usuarioId) throw new Error('Auth requerida')
|
if (!usuarioId) throw new Error('Auth requerida')
|
||||||
const completadosStr = req.query['completados'] as string | undefined
|
const completadosStr = req.query['completados'] as string | undefined
|
||||||
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null } = {
|
const opts: {
|
||||||
|
completados?: boolean
|
||||||
|
desde?: string | null
|
||||||
|
hasta?: string | null
|
||||||
|
clienteClave?: string | null
|
||||||
|
} = {
|
||||||
desde: (req.query['desde'] as string) ?? null,
|
desde: (req.query['desde'] as string) ?? null,
|
||||||
hasta: (req.query['hasta'] as string) ?? null,
|
hasta: (req.query['hasta'] as string) ?? null,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { resumenMora } from '../services/mora.service.js'
|
|||||||
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
|
/** Singleton del agregador (cache en memoria de 60 s; los tests instancian el suyo) */
|
||||||
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
|
export const reportesService = new ReportesService(mondayService, cargarPlanesDominio)
|
||||||
|
|
||||||
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta */
|
/** GET /api/reportes/resumen — todos los KPIs del dashboard en una sola respuesta
|
||||||
export async function resumenReportes(_req: Request, res: Response): Promise<void> {
|
* ?dias=7|30|90 acota leads/oportunidades al rango (default: todo el histórico) */
|
||||||
res.json(await reportesService.resumen())
|
export async function resumenReportes(req: Request, res: Response): Promise<void> {
|
||||||
|
const dias = Number(req.query['dias'])
|
||||||
|
res.json(await reportesService.resumen(Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/reportes/scoring — scores de todos los leads activos */
|
/** GET /api/reportes/scoring — scores de todos los leads activos */
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('actualizarVinculoMondayEndpoint (PATCH /api/usuarios/me/monday)', () =
|
|||||||
rol: 'vendedor',
|
rol: 'vendedor',
|
||||||
asignacion: 'MONTEBELLO',
|
asignacion: 'MONTEBELLO',
|
||||||
mondayUserId: 13676,
|
mondayUserId: 13676,
|
||||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
|
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
|
||||||
acciones: [],
|
acciones: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe('login', () => {
|
|||||||
rol: 'vendedor',
|
rol: 'vendedor',
|
||||||
asignacion: 'MONTEBELLO',
|
asignacion: 'MONTEBELLO',
|
||||||
mondayUserId: null,
|
mondayUserId: null,
|
||||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat'],
|
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
|
||||||
acciones: [],
|
acciones: [],
|
||||||
})
|
})
|
||||||
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
|
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
|
||||||
@@ -103,7 +103,7 @@ describe('me', () => {
|
|||||||
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
|
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
|
||||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
|
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
|
||||||
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
|
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
|
||||||
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat'])
|
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rechaza si el usuario ya no existe o está inactivo', async () => {
|
it('rechaza si el usuario ya no existe o está inactivo', async () => {
|
||||||
|
|||||||
@@ -602,7 +602,7 @@ export class AutomatizacionesService {
|
|||||||
pendienteSync: false,
|
pendienteSync: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (e) { console.error('[APARTADO-ERR]', e.message?.slice(0,150)) }
|
} catch (e) { console.error('[APARTADO-ERR]', e instanceof Error ? e.message.slice(0, 150) : e) }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
|
resultado.errores.push({ oportunidadId: op.id, mensaje: err instanceof Error ? err.message : String(err) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ describe('detalleContacto (GET /api/contactos/:id/detalle)', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
expect(d).not.toBeNull()
|
expect(d).not.toBeNull()
|
||||||
expect(d!.contacto['CURP']).toBe('XAXX010101XXXXXXX0')
|
// El contacto del detalle se devuelve con campos nombrados del modelo local
|
||||||
|
expect(d!.contacto['curp']).toBe('XAXX010101XXXXXXX0')
|
||||||
// Propiedad comprada (apartado relacionado, enriquecido con el desarrollo)
|
// Propiedad comprada (apartado relacionado, enriquecido con el desarrollo)
|
||||||
expect(d!.propiedad).toMatchObject({
|
expect(d!.propiedad).toMatchObject({
|
||||||
unidadCodigo: 'SENDA-409-1-2',
|
unidadCodigo: 'SENDA-409-1-2',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface EventoHistorialContacto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DetalleContacto {
|
export interface DetalleContacto {
|
||||||
contacto: Record<string, string> // columnas del board por título ('nombre' incluido)
|
contacto: Record<string, string> // campos nombrados del modelo local (nombre, telefono, email, …)
|
||||||
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
|
/** Apartado relacionado más reciente (la propiedad comprada o en proceso) */
|
||||||
propiedad: {
|
propiedad: {
|
||||||
id: string
|
id: string
|
||||||
@@ -57,6 +57,32 @@ export function claveTelefono(telefono: string): string {
|
|||||||
return digitos.length >= 10 ? digitos.slice(-10) : ''
|
return digitos.length >= 10 ? digitos.slice(-10) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapeo título de columna del board Contactos (Monday) → campo nombrado del
|
||||||
|
* modelo local. Los títulos sin entrada se conservan tal cual.
|
||||||
|
*/
|
||||||
|
const CAMPOS_CONTACTO: Record<string, string> = {
|
||||||
|
nombre: 'nombre',
|
||||||
|
'Teléfono': 'telefono',
|
||||||
|
Email: 'email',
|
||||||
|
Estado: 'estado',
|
||||||
|
CURP: 'curp',
|
||||||
|
RFC: 'rfc',
|
||||||
|
'Estado Civil': 'estadoCivil',
|
||||||
|
'Fecha Nacimiento': 'fechaNacimiento',
|
||||||
|
'Actividad Economica': 'actividadEconomica',
|
||||||
|
'Dirección': 'direccion',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normaliza un record por título de columna a campos nombrados del modelo local */
|
||||||
|
export function normalizarCamposContacto(contacto: Record<string, string>): Record<string, string> {
|
||||||
|
const normalizado: Record<string, string> = {}
|
||||||
|
for (const [titulo, valor] of Object.entries(contacto)) {
|
||||||
|
normalizado[CAMPOS_CONTACTO[titulo] ?? titulo] = valor
|
||||||
|
}
|
||||||
|
return normalizado
|
||||||
|
}
|
||||||
|
|
||||||
type MondayDetalle = Pick<
|
type MondayDetalle = Pick<
|
||||||
MondayService,
|
MondayService,
|
||||||
| 'getContactoDetalle'
|
| 'getContactoDetalle'
|
||||||
@@ -169,7 +195,7 @@ export async function detalleContacto(
|
|||||||
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contacto,
|
contacto: normalizarCamposContacto(contacto),
|
||||||
propiedad,
|
propiedad,
|
||||||
relaciones: {
|
relaciones: {
|
||||||
leads: relLeads.map((l) => ({
|
leads: relLeads.map((l) => ({
|
||||||
|
|||||||
@@ -162,10 +162,11 @@ describe('detalleContactoLocal', () => {
|
|||||||
|
|
||||||
expect(detalle.contacto).toMatchObject({
|
expect(detalle.contacto).toMatchObject({
|
||||||
nombre: 'JUAN PÉREZ',
|
nombre: 'JUAN PÉREZ',
|
||||||
Teléfono: '4771234567',
|
telefono: '4771234567',
|
||||||
Email: 'juan@correo.mx',
|
email: 'juan@correo.mx',
|
||||||
CURP: 'CURP123',
|
estado: 'Cliente',
|
||||||
RFC: 'RFC123',
|
curp: 'CURP123',
|
||||||
|
rfc: 'RFC123',
|
||||||
})
|
})
|
||||||
expect(detalle.propiedad).toMatchObject({
|
expect(detalle.propiedad).toMatchObject({
|
||||||
id: 'loc_a1',
|
id: 'loc_a1',
|
||||||
@@ -203,7 +204,7 @@ describe('detalleContactoLocal', () => {
|
|||||||
const detalle = await detalleContactoLocal('loc_1')
|
const detalle = await detalleContactoLocal('loc_1')
|
||||||
expect(detalle).not.toBeNull()
|
expect(detalle).not.toBeNull()
|
||||||
if (detalle === null) return
|
if (detalle === null) return
|
||||||
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', Teléfono: '6649998877', Estado: 'Afluencia' })
|
expect(detalle.contacto).toMatchObject({ nombre: 'ANA DEMO', telefono: '6649998877', estado: 'Afluencia' })
|
||||||
expect(detalle.propiedad).toBeNull()
|
expect(detalle.propiedad).toBeNull()
|
||||||
expect(detalle.relaciones.oportunidades).toHaveLength(1)
|
expect(detalle.relaciones.oportunidades).toHaveLength(1)
|
||||||
expect(detalle.historial.length).toBeGreaterThan(0)
|
expect(detalle.historial.length).toBeGreaterThan(0)
|
||||||
|
|||||||
@@ -202,17 +202,17 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
|
|||||||
cliente !== null
|
cliente !== null
|
||||||
? {
|
? {
|
||||||
nombre: cliente.nombreCompleto,
|
nombre: cliente.nombreCompleto,
|
||||||
Teléfono: cliente.celular ?? '',
|
telefono: cliente.celular ?? '',
|
||||||
Email: cliente.email ?? '',
|
email: cliente.email ?? '',
|
||||||
CURP: cliente.curp,
|
estado: 'Cliente',
|
||||||
RFC: cliente.rfc,
|
curp: cliente.curp,
|
||||||
Estado: 'Cliente',
|
rfc: cliente.rfc,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
nombre: capturaBase!.nombreLead,
|
nombre: capturaBase!.nombreLead,
|
||||||
Teléfono: capturaBase!.telefono ?? '',
|
telefono: capturaBase!.telefono ?? '',
|
||||||
Email: capturaBase!.email ?? '',
|
email: capturaBase!.email ?? '',
|
||||||
Estado: capturaBase!.etapa,
|
estado: capturaBase!.etapa,
|
||||||
}
|
}
|
||||||
|
|
||||||
return { contacto, propiedad, relaciones, historial }
|
return { contacto, propiedad, relaciones, historial }
|
||||||
|
|||||||
@@ -218,7 +218,14 @@ export async function resumenMiDia(
|
|||||||
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
|
/** mondayUserId -> nombre para mostrar (usuarios del ecosistema ya mapeados) */
|
||||||
nombresPorMondayId: Map<number, string>,
|
nombresPorMondayId: Map<number, string>,
|
||||||
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
||||||
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
|
capturas: {
|
||||||
|
leadId: string
|
||||||
|
nombreLead: string
|
||||||
|
telefono: string | null
|
||||||
|
createdAt: Date
|
||||||
|
/** Oportunidad ligada (LeadOportunidad); null si la captura aún no convertió */
|
||||||
|
oportunidadId?: string | null
|
||||||
|
}[] = [],
|
||||||
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
|
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
|
||||||
clientes: {
|
clientes: {
|
||||||
id: string
|
id: string
|
||||||
@@ -332,7 +339,10 @@ export async function resumenMiDia(
|
|||||||
)
|
)
|
||||||
// Set de oportunidades mías (por LeadCaptura o por vendedorIds)
|
// Set de oportunidades mías (por LeadCaptura o por vendedorIds)
|
||||||
const miosOportunidadesIds = new Set(
|
const miosOportunidadesIds = new Set(
|
||||||
capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId),
|
capturas
|
||||||
|
.filter((c) => c.oportunidadId)
|
||||||
|
.map((c) => c.oportunidadId)
|
||||||
|
.filter((id): id is string => id != null),
|
||||||
)
|
)
|
||||||
const miosApartadosTodos = apartados.filter((a) =>
|
const miosApartadosTodos = apartados.filter((a) =>
|
||||||
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
|
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
|
||||||
@@ -387,7 +397,8 @@ export async function resumenMiDia(
|
|||||||
estado: a.estado,
|
estado: a.estado,
|
||||||
fechaLimite: a.fechaLimite,
|
fechaLimite: a.fechaLimite,
|
||||||
ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null,
|
ultimoCambio: null as { fechaISO: string; usuario: string | null; detalle: string } | null,
|
||||||
pendienteSync: a.pendienteSync,
|
// Los apartados leídos de Monday ya están sincronizados por definición
|
||||||
|
pendienteSync: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const misApartados: ApartadoMiDia[] = miosApartados
|
const misApartados: ApartadoMiDia[] = miosApartados
|
||||||
@@ -874,9 +885,9 @@ export function resumenMiDiaLocal(
|
|||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.leadId,
|
id: c.leadId,
|
||||||
nombre: c.nombreLead,
|
nombre: c.nombreLead,
|
||||||
unidadCodigo: '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: '',
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -1672,6 +1672,8 @@ export interface DatosOportunidadAfluencia {
|
|||||||
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
|
tipoEconomiaLabel?: string | undefined // status 'Tipo de Econimia' (SIC: typo real)
|
||||||
calificaLabel?: string | undefined // dropdown 'Lead Califica'
|
calificaLabel?: string | undefined // dropdown 'Lead Califica'
|
||||||
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
|
loQuierePara?: string | undefined // dropdown 'Proposito de Compra'
|
||||||
|
/** text '# Dependientes' (si el board aún no tiene la columna, Monday lo ignora) */
|
||||||
|
numDependientes?: string | undefined
|
||||||
notas?: string | undefined // long_text 'COMENTARIOS'
|
notas?: string | undefined // long_text 'COMENTARIOS'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,12 +75,14 @@ describe('construirMiTablero', () => {
|
|||||||
captura({ board: 'OPORTUNIDADES' }),
|
captura({ board: 'OPORTUNIDADES' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
||||||
|
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
||||||
]
|
]
|
||||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||||
expect(tablero.conversion.leads).toBe(2)
|
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
|
||||||
expect(tablero.conversion.oportunidades).toBe(1)
|
expect(tablero.conversion.oportunidades).toBe(1)
|
||||||
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
|
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
|
||||||
expect(tablero.conversion.vendidos).toBe(1)
|
expect(tablero.conversion.vendidos).toBe(1)
|
||||||
|
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calcula ventas del mes correctamente', () => {
|
it('calcula ventas del mes correctamente', () => {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface MiTablero {
|
|||||||
oportunidades: number
|
oportunidades: number
|
||||||
apartados: number
|
apartados: number
|
||||||
vendidos: number
|
vendidos: number
|
||||||
|
/** Leads descartados (etapa terminal DESCARTADO); etapa extra al final del funnel */
|
||||||
|
descartados: number
|
||||||
}
|
}
|
||||||
ventasMes: {
|
ventasMes: {
|
||||||
monto: number
|
monto: number
|
||||||
@@ -125,6 +127,7 @@ export function construirMiTablero(
|
|||||||
c.board === 'APARTADO' &&
|
c.board === 'APARTADO' &&
|
||||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
||||||
).length
|
).length
|
||||||
|
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
||||||
|
|
||||||
// -- Ventas del mes --
|
// -- Ventas del mes --
|
||||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||||
@@ -183,7 +186,7 @@ export function construirMiTablero(
|
|||||||
return {
|
return {
|
||||||
generadoEn: ahora.toISOString(),
|
generadoEn: ahora.toISOString(),
|
||||||
leadsPorDia,
|
leadsPorDia,
|
||||||
conversion: { leads, oportunidades, apartados, vendidos },
|
conversion: { leads, oportunidades, apartados, vendidos, descartados },
|
||||||
ventasMes,
|
ventasMes,
|
||||||
porOrigen,
|
porOrigen,
|
||||||
porDesarrollo,
|
porDesarrollo,
|
||||||
@@ -203,6 +206,7 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
|
|||||||
const capturas = await prisma.leadCaptura.findMany({
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
where: { usuarioId },
|
where: { usuarioId },
|
||||||
select: {
|
select: {
|
||||||
|
leadId: true,
|
||||||
board: true,
|
board: true,
|
||||||
etapa: true,
|
etapa: true,
|
||||||
origen: true,
|
origen: true,
|
||||||
@@ -246,11 +250,11 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
|
|||||||
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
|
||||||
|
|
||||||
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
|
// Filtro por usuario específico (coordinador ve solo su equipo; admin ve todos)
|
||||||
const whereUsuario = usuarioId ? { usuarioId } : undefined
|
const usuarioFiltro = usuarioId ?? undefined
|
||||||
|
|
||||||
const [capturas, actividades, usuarios] = await Promise.all([
|
const [capturas, actividades, usuarios] = await Promise.all([
|
||||||
prisma.leadCaptura.findMany({
|
prisma.leadCaptura.findMany({
|
||||||
where: whereUsuario,
|
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
|
||||||
select: {
|
select: {
|
||||||
usuarioId: true,
|
usuarioId: true,
|
||||||
etapa: true,
|
etapa: true,
|
||||||
@@ -258,14 +262,14 @@ export async function obtenerMetricasUso(usuarioId?: string): Promise<MetricasUs
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.actividadLocal.findMany({
|
prisma.actividadLocal.findMany({
|
||||||
where: whereUsuario,
|
...(usuarioFiltro ? { where: { usuarioId: usuarioFiltro } } : {}),
|
||||||
select: {
|
select: {
|
||||||
usuarioId: true,
|
usuarioId: true,
|
||||||
estado: true,
|
estado: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.usuario.findMany({
|
prisma.usuario.findMany({
|
||||||
where: whereUsuario ? { id: usuarioId } : { activo: true },
|
where: usuarioFiltro ? { id: usuarioFiltro } : { activo: true },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
nombre: true,
|
nombre: true,
|
||||||
|
|||||||
@@ -168,23 +168,38 @@ function inicioDelDia(d: Date): Date {
|
|||||||
/**
|
/**
|
||||||
* Construye el resumen a partir de los datos crudos. Función pura: toda la
|
* Construye el resumen a partir de los datos crudos. Función pura: toda la
|
||||||
* lógica de conteo/tasas se prueba sin red ni base de datos.
|
* lógica de conteo/tasas se prueba sin red ni base de datos.
|
||||||
|
*
|
||||||
|
* `dias` (opcional, 7/30/90) acota leads y oportunidades a las capturas del
|
||||||
|
* rango (por createdAt); inventario, cobranza y actividades son posición
|
||||||
|
* actual y no se filtran. Sin `dias` se cuenta TODO el histórico (igual que antes).
|
||||||
*/
|
*/
|
||||||
export function construirResumen(datos: DatosResumen, ahora: Date): ResumenReportes {
|
export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number): ResumenReportes {
|
||||||
const hoy = inicioDelDia(ahora)
|
const hoy = inicioDelDia(ahora)
|
||||||
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
const hace7dias = new Date(hoy.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||||
|
const rangoValido = dias !== undefined && Number.isFinite(dias) && dias > 0
|
||||||
|
const desdeRango = rangoValido
|
||||||
|
? new Date(hoy.getTime() - (Math.floor(dias!) - 1) * 24 * 60 * 60 * 1000)
|
||||||
|
: null
|
||||||
|
const enRango = (createdAt: string): boolean => {
|
||||||
|
if (!desdeRango) return true
|
||||||
|
const t = Date.parse(createdAt)
|
||||||
|
return !Number.isNaN(t) && t >= desdeRango.getTime()
|
||||||
|
}
|
||||||
|
const leadsRango = datos.leads.filter((l) => enRango(l.createdAt))
|
||||||
|
const oportunidadesRango = datos.oportunidades.filter((o) => enRango(o.createdAt))
|
||||||
const creadosEn = (desde: Date): number =>
|
const creadosEn = (desde: Date): number =>
|
||||||
datos.leads.filter((l) => {
|
leadsRango.filter((l) => {
|
||||||
const t = Date.parse(l.createdAt)
|
const t = Date.parse(l.createdAt)
|
||||||
return !Number.isNaN(t) && t >= desde.getTime()
|
return !Number.isNaN(t) && t >= desde.getTime()
|
||||||
}).length
|
}).length
|
||||||
|
|
||||||
const porEtapa = contarPor(datos.oportunidades.map((o) => o.etapa))
|
const porEtapa = contarPor(oportunidadesRango.map((o) => o.etapa))
|
||||||
const totalOps = datos.oportunidades.length
|
const totalOps = oportunidadesRango.length
|
||||||
const ganadas = datos.oportunidades.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
|
const ganadas = oportunidadesRango.filter((o) => o.etapa.trim().toUpperCase() === 'GANADO').length
|
||||||
const apartadosOMas = datos.oportunidades.filter((o) =>
|
const apartadosOMas = oportunidadesRango.filter((o) =>
|
||||||
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
|
['APARTO', 'GANADO'].includes(o.etapa.trim().toUpperCase()),
|
||||||
).length
|
).length
|
||||||
const activas = datos.oportunidades.filter(
|
const activas = oportunidadesRango.filter(
|
||||||
(o) =>
|
(o) =>
|
||||||
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
|
!ETAPAS_CERRADAS.includes(o.etapa.trim().toUpperCase()) &&
|
||||||
o.etapa.trim().toUpperCase() !== 'GANADO',
|
o.etapa.trim().toUpperCase() !== 'GANADO',
|
||||||
@@ -233,28 +248,28 @@ export function construirResumen(datos: DatosResumen, ahora: Date): ResumenRepor
|
|||||||
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
|
// Sección "leads" = tablero Leads (captura ligera); el perfilamiento de la
|
||||||
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
|
// afluencia se lee de Oportunidades (el Alta Afluencia vive ahí)
|
||||||
leads: {
|
leads: {
|
||||||
total: datos.leads.length,
|
total: leadsRango.length,
|
||||||
nuevosHoy: creadosEn(hoy),
|
nuevosHoy: creadosEn(hoy),
|
||||||
nuevosSemana: creadosEn(hace7dias),
|
nuevosSemana: creadosEn(hace7dias),
|
||||||
porEstado: contarPor(datos.leads.map((l) => l.estado)),
|
porEstado: contarPor(leadsRango.map((l) => l.estado)),
|
||||||
porOrigen: contarPor(datos.leads.map((l) => l.origen)),
|
porOrigen: contarPor(leadsRango.map((l) => l.origen)),
|
||||||
perfil: {
|
perfil: {
|
||||||
porDesarrollo: contarPorNoVacios(datos.oportunidades.map((o) => o.desarrollo ?? '')),
|
porDesarrollo: contarPorNoVacios(oportunidadesRango.map((o) => o.desarrollo ?? '')),
|
||||||
porModelo: contarPorNoVacios(datos.oportunidades.map((o) => o.modeloInteres ?? '')),
|
porModelo: contarPorNoVacios(oportunidadesRango.map((o) => o.modeloInteres ?? '')),
|
||||||
porEstadoCivil: contarPorNoVacios(datos.oportunidades.map((o) => o.estadoCivil ?? '')),
|
porEstadoCivil: contarPorNoVacios(oportunidadesRango.map((o) => o.estadoCivil ?? '')),
|
||||||
porTipoEmpleo: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEmpleo ?? '')),
|
porTipoEmpleo: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEmpleo ?? '')),
|
||||||
porTipoEconomia: contarPorNoVacios(datos.oportunidades.map((o) => o.tipoEconomia ?? '')),
|
porTipoEconomia: contarPorNoVacios(oportunidadesRango.map((o) => o.tipoEconomia ?? '')),
|
||||||
porCalifica: contarPorNoVacios(datos.oportunidades.map((o) => o.califica ?? '')),
|
porCalifica: contarPorNoVacios(oportunidadesRango.map((o) => o.califica ?? '')),
|
||||||
porCredito: contarPorNoVacios(datos.oportunidades.map((o) => o.credito ?? '')),
|
porCredito: contarPorNoVacios(oportunidadesRango.map((o) => o.credito ?? '')),
|
||||||
porRangoSalarial: contarPorNoVacios(
|
porRangoSalarial: contarPorNoVacios(
|
||||||
datos.oportunidades
|
oportunidadesRango
|
||||||
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
|
.map((o) => bucketSalario(o.rangoSalarial ?? ''))
|
||||||
.filter((b): b is string => b !== null),
|
.filter((b): b is string => b !== null),
|
||||||
),
|
),
|
||||||
porMedioContacto: contarPorNoVacios(datos.oportunidades.map((o) => o.medioContacto ?? '')),
|
porMedioContacto: contarPorNoVacios(oportunidadesRango.map((o) => o.medioContacto ?? '')),
|
||||||
porLoQuierePara: contarPorNoVacios(datos.oportunidades.map((o) => o.loQuierePara ?? '')),
|
porLoQuierePara: contarPorNoVacios(oportunidadesRango.map((o) => o.loQuierePara ?? '')),
|
||||||
porEdad: contarPorNoVacios(
|
porEdad: contarPorNoVacios(
|
||||||
datos.oportunidades.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
|
oportunidadesRango.filter((o) => o.edad != null).map((o) => bucketEdad(o.edad!)),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -391,7 +406,8 @@ export class ReportesService {
|
|||||||
private readonly cacheMs: number
|
private readonly cacheMs: number
|
||||||
private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
|
private readonly salud: Pick<SaludMondayService, 'estaDisponible'>
|
||||||
private readonly cargarDatosLocales: CargadorDatosLocales
|
private readonly cargarDatosLocales: CargadorDatosLocales
|
||||||
private cache: { resumen: ResumenReportes; expira: number } | null = null
|
/** Cache por rango (clave '0' = todo el histórico) para no mezclar ventanas */
|
||||||
|
private cache: Map<string, { resumen: ResumenReportes; expira: number }> = new Map()
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly monday: MondayReportes,
|
private readonly monday: MondayReportes,
|
||||||
@@ -406,15 +422,18 @@ export class ReportesService {
|
|||||||
|
|
||||||
/** Invalida el cache (útil tras escrituras que cambian los números) */
|
/** Invalida el cache (útil tras escrituras que cambian los números) */
|
||||||
invalidarCache(): void {
|
invalidarCache(): void {
|
||||||
this.cache = null
|
this.cache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumen(): Promise<ResumenReportes> {
|
async resumen(dias?: number): Promise<ResumenReportes> {
|
||||||
|
const rango = dias !== undefined && Number.isFinite(dias) && dias > 0 ? Math.floor(dias) : undefined
|
||||||
|
const clave = String(rango ?? 0)
|
||||||
const ahoraMs = this.now()
|
const ahoraMs = this.now()
|
||||||
if (this.cache && this.cache.expira > ahoraMs) return this.cache.resumen
|
const hit = this.cache.get(clave)
|
||||||
|
if (hit && hit.expira > ahoraMs) return hit.resumen
|
||||||
|
|
||||||
const resumen = await this.cargarResumen(ahoraMs)
|
const resumen = await this.cargarResumen(ahoraMs, rango)
|
||||||
this.cache = { resumen, expira: ahoraMs + this.cacheMs }
|
this.cache.set(clave, { resumen, expira: ahoraMs + this.cacheMs })
|
||||||
return resumen
|
return resumen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +442,7 @@ export class ReportesService {
|
|||||||
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
|
* de siempre). Camino degradado: Monday caído (circuit breaker) o AppError
|
||||||
* 502 a media lectura → resumen desde tablas locales con `parcial: true`.
|
* 502 a media lectura → resumen desde tablas locales con `parcial: true`.
|
||||||
*/
|
*/
|
||||||
private async cargarResumen(ahoraMs: number): Promise<ResumenReportes> {
|
private async cargarResumen(ahoraMs: number, dias?: number): Promise<ResumenReportes> {
|
||||||
if (this.salud.estaDisponible()) {
|
if (this.salud.estaDisponible()) {
|
||||||
try {
|
try {
|
||||||
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
// Las fuentes de Monday se piden en paralelo; Prisma va por su lado
|
||||||
@@ -434,7 +453,7 @@ export class ReportesService {
|
|||||||
this.monday.getActividadesAbiertas(),
|
this.monday.getActividadesAbiertas(),
|
||||||
this.cargarPlanes(),
|
this.cargarPlanes(),
|
||||||
])
|
])
|
||||||
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs))
|
return construirResumen({ leads, oportunidades, unidades, planes, actividadesAbiertas }, new Date(ahoraMs), dias)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof AppError && err.status === 502)) throw err
|
if (!(err instanceof AppError && err.status === 502)) throw err
|
||||||
// Monday se cayó a media lectura: cae al camino local
|
// Monday se cayó a media lectura: cae al camino local
|
||||||
@@ -444,6 +463,7 @@ export class ReportesService {
|
|||||||
const resumen = construirResumen(
|
const resumen = construirResumen(
|
||||||
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
|
{ ...locales, planes, actividadesAbiertas: locales.actividadesAbiertas ?? 0 },
|
||||||
new Date(ahoraMs),
|
new Date(ahoraMs),
|
||||||
|
dias,
|
||||||
)
|
)
|
||||||
// En modo local, los datos son completos (no parciales): todas las métricas
|
// En modo local, los datos son completos (no parciales): todas las métricas
|
||||||
// vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó
|
// vienen de tablas locales. `parcial` solo se marca true cuando Monday cayó
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import Burbuja from '../components/Burbuja'
|
import Burbuja from '../components/Burbuja'
|
||||||
import DarkModeToggle from '../components/DarkModeToggle'
|
import DarkModeToggle from '../components/DarkModeToggle'
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
|
import {
|
||||||
|
enviarMensaje,
|
||||||
|
esErrorDeRed,
|
||||||
|
listarConversaciones,
|
||||||
|
obtenerHistorial,
|
||||||
|
type ConversacionResumen,
|
||||||
|
} from '../services/api'
|
||||||
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
||||||
import type { Mensaje } from '../types'
|
import type { Mensaje } from '../types'
|
||||||
|
|
||||||
@@ -23,6 +29,12 @@ export default function ChatPage() {
|
|||||||
const [escribiendo, setEscribiendo] = useState(false)
|
const [escribiendo, setEscribiendo] = useState(false)
|
||||||
const [sinConexion, setSinConexion] = useState(false)
|
const [sinConexion, setSinConexion] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// Texto del último envío fallido (habilita "Reintentar" junto al input)
|
||||||
|
const [pendiente, setPendiente] = useState<string | null>(null)
|
||||||
|
// Panel de conversaciones previas
|
||||||
|
const [panelAbierto, setPanelAbierto] = useState(false)
|
||||||
|
const [conversaciones, setConversaciones] = useState<ConversacionResumen[] | null>(null)
|
||||||
|
const [cargandoConversaciones, setCargandoConversaciones] = useState(false)
|
||||||
const finalRef = useRef<HTMLDivElement>(null)
|
const finalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Al cargar: si hay sesión en localStorage, recupera su historial del API
|
// Al cargar: si hay sesión en localStorage, recupera su historial del API
|
||||||
@@ -41,18 +53,15 @@ export default function ChatPage() {
|
|||||||
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
|
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [mensajes, escribiendo])
|
}, [mensajes, escribiendo])
|
||||||
|
|
||||||
async function enviar() {
|
/** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */
|
||||||
const texto = entrada.trim()
|
async function transmitir(texto: string) {
|
||||||
if (!texto || escribiendo) return
|
|
||||||
setEntrada('')
|
|
||||||
setError(null)
|
|
||||||
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
|
|
||||||
setEscribiendo(true)
|
setEscribiendo(true)
|
||||||
try {
|
try {
|
||||||
const r = await enviarMensaje(sessionId, texto)
|
const r = await enviarMensaje(sessionId, texto)
|
||||||
setSessionId(r.sessionId)
|
setSessionId(r.sessionId)
|
||||||
guardarSesion(r.sessionId)
|
guardarSesion(r.sessionId)
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
|
setPendiente(null)
|
||||||
setMensajes((prev) => [
|
setMensajes((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
|
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
|
||||||
@@ -63,18 +72,63 @@ export default function ChatPage() {
|
|||||||
} else {
|
} else {
|
||||||
toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
|
toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
|
||||||
}
|
}
|
||||||
// el mensaje del usuario se queda visible; puede reintentar
|
setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar
|
||||||
} finally {
|
} finally {
|
||||||
setEscribiendo(false)
|
setEscribiendo(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enviar() {
|
||||||
|
const texto = entrada.trim()
|
||||||
|
if (!texto || escribiendo) return
|
||||||
|
setEntrada('')
|
||||||
|
setError(null)
|
||||||
|
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
|
||||||
|
await transmitir(texto)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */
|
||||||
|
async function reintentar() {
|
||||||
|
if (!pendiente || escribiendo) return
|
||||||
|
setError(null)
|
||||||
|
setSinConexion(false)
|
||||||
|
await transmitir(pendiente)
|
||||||
|
}
|
||||||
|
|
||||||
function nuevaConversacion() {
|
function nuevaConversacion() {
|
||||||
limpiarSesion()
|
limpiarSesion()
|
||||||
setSessionId(null)
|
setSessionId(null)
|
||||||
setMensajes([])
|
setMensajes([])
|
||||||
setError(null)
|
setError(null)
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
|
setPendiente(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abre el panel y carga (una vez) el listado de conversaciones del usuario */
|
||||||
|
function abrirPanel() {
|
||||||
|
setPanelAbierto(true)
|
||||||
|
if (conversaciones !== null || cargandoConversaciones) return
|
||||||
|
setCargandoConversaciones(true)
|
||||||
|
listarConversaciones()
|
||||||
|
.then(setConversaciones)
|
||||||
|
.catch(() => setConversaciones([]))
|
||||||
|
.finally(() => setCargandoConversaciones(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carga el historial de una conversación previa y la vuelve la activa */
|
||||||
|
async function abrirConversacion(id: string) {
|
||||||
|
try {
|
||||||
|
const historial = await obtenerHistorial(id)
|
||||||
|
setSessionId(id)
|
||||||
|
guardarSesion(id)
|
||||||
|
setMensajes(historial)
|
||||||
|
setPendiente(null)
|
||||||
|
setError(null)
|
||||||
|
setSinConexion(false)
|
||||||
|
setPanelAbierto(false)
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudo cargar la conversación')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,6 +150,14 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DarkModeToggle />
|
<DarkModeToggle />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={abrirPanel}
|
||||||
|
aria-expanded={panelAbierto}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
Historial
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={nuevaConversacion}
|
onClick={nuevaConversacion}
|
||||||
@@ -163,6 +225,20 @@ export default function ChatPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{pendiente && !escribiendo && (
|
||||||
|
<div className="flex items-center justify-between gap-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-red-600">
|
||||||
|
No se pudo enviar tu último mensaje.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void reintentar()}
|
||||||
|
className="rounded-lg bg-accent px-4 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form
|
<form
|
||||||
className="flex items-stretch gap-3 py-4"
|
className="flex items-stretch gap-3 py-4"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -192,6 +268,64 @@ export default function ChatPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{/* Panel lateral: conversaciones previas del usuario */}
|
||||||
|
{panelAbierto && (
|
||||||
|
<div className="fixed inset-0 z-30 flex justify-end bg-black/40" onClick={() => setPanelAbierto(false)}>
|
||||||
|
<aside
|
||||||
|
className="h-full w-80 max-w-[85vw] overflow-y-auto border-l border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B]"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Conversaciones previas"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Conversaciones previas</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPanelAbierto(false)}
|
||||||
|
aria-label="Cerrar historial"
|
||||||
|
className="rounded-lg px-2 py-1 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{cargandoConversaciones ? (
|
||||||
|
<p className="mt-6 text-sm text-[#94A3B8]">Cargando…</p>
|
||||||
|
) : !conversaciones || conversaciones.length === 0 ? (
|
||||||
|
<p className="mt-6 text-sm text-[#94A3B8]">Sin conversaciones previas.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 space-y-2">
|
||||||
|
{conversaciones.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void abrirConversacion(c.id)}
|
||||||
|
className={`w-full rounded-lg border p-3 text-left transition-colors ${
|
||||||
|
c.id === sessionId
|
||||||
|
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
|
||||||
|
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="truncate text-xs text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[10px] text-[#94A3B8]">
|
||||||
|
{new Date(c.createdAt).toLocaleString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,3 +73,14 @@ export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
|
|||||||
fuentes: m.fuentes ?? [],
|
fuentes: m.fuentes ?? [],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConversacionResumen {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
ultimoMensaje: { rol: string; contenido: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /ai/conversations — conversaciones previas del usuario (más reciente primero) */
|
||||||
|
export function listarConversaciones(): Promise<ConversacionResumen[]> {
|
||||||
|
return request<ConversacionResumen[]>('/ai/conversations')
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
TipoCredito,
|
TipoCredito,
|
||||||
SlotDocumento,
|
SlotDocumento,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import { getSesion } from '../services/auth'
|
||||||
|
|
||||||
export const PROYECTOS = [
|
export const PROYECTOS = [
|
||||||
'RESIDENCIAL MONTEBELLO',
|
'RESIDENCIAL MONTEBELLO',
|
||||||
@@ -102,5 +103,9 @@ export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOL
|
|||||||
// El modelo de interés sale de MODELOS_POR_PROYECTO según el desarrollo elegido.
|
// El modelo de interés sale de MODELOS_POR_PROYECTO según el desarrollo elegido.
|
||||||
export const OPCIONES_CALIFICA = ['Sí', 'No', 'Por confirmar'] as const
|
export const OPCIONES_CALIFICA = ['Sí', 'No', 'Por confirmar'] as const
|
||||||
|
|
||||||
export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ'
|
/**
|
||||||
|
* Nombre del asesor para el campo de firma del alta: se toma de la sesión
|
||||||
|
* activa en el momento en que arranca el alta. Sin sesión queda vacío.
|
||||||
|
*/
|
||||||
|
export const asesorDeSesion = (): string => getSesion()?.usuario.nombre ?? ''
|
||||||
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'
|
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { useForm, FormProvider } from 'react-hook-form'
|
import { useForm, FormProvider, useFieldArray } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import type { Referencia } from '../types'
|
import type { Referencia } from '../types'
|
||||||
import { referenciasSchema } from '../lib/schemas'
|
import { referenciasSchema } from '../lib/schemas'
|
||||||
@@ -11,13 +11,18 @@ import type { PasoProps } from './DatosGeneralesForm'
|
|||||||
// RHF trabaja con objetos raíz; el array va envuelto
|
// RHF trabaja con objetos raíz; el array va envuelto
|
||||||
const schemaFormulario = z.object({ referencias: referenciasSchema })
|
const schemaFormulario = z.object({ referencias: referenciasSchema })
|
||||||
|
|
||||||
/** Paso 5: referencias personales (4 filas, mínimo 1 con nombre y teléfono) */
|
const MAX_REFERENCIAS = 4
|
||||||
|
|
||||||
|
const referenciaVacia = (): Referencia => ({ nombre: '', telefono: '', parentesco: '', direccion: '' })
|
||||||
|
|
||||||
|
/** Paso 5: referencias personales (1 a 4 bloques; al menos 1 con nombre y teléfono) */
|
||||||
export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: PasoProps<Referencia[]>) {
|
export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: PasoProps<Referencia[]>) {
|
||||||
const metodos = useForm<{ referencias: Referencia[] }>({
|
const metodos = useForm<{ referencias: Referencia[] }>({
|
||||||
resolver: zodResolver(schemaFormulario),
|
resolver: zodResolver(schemaFormulario),
|
||||||
defaultValues: { referencias: valores },
|
defaultValues: { referencias: valores },
|
||||||
mode: 'onSubmit',
|
mode: 'onSubmit',
|
||||||
})
|
})
|
||||||
|
const { fields, append, remove } = useFieldArray({ control: metodos.control, name: 'referencias' })
|
||||||
const errorRaiz = metodos.formState.errors.referencias
|
const errorRaiz = metodos.formState.errors.referencias
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,9 +35,24 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
|
|||||||
titulo="Referencias personales"
|
titulo="Referencias personales"
|
||||||
descripcion="Captura al menos 1 referencia con nombre y teléfono."
|
descripcion="Captura al menos 1 referencia con nombre y teléfono."
|
||||||
>
|
>
|
||||||
{[0, 1, 2, 3].map((i) => (
|
{fields.map((field, i) => (
|
||||||
<div key={i} className="rounded-xl border border-[#E5E7EB] p-6">
|
<div key={field.id} className="rounded-xl border border-[#E5E7EB] p-6">
|
||||||
<p className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B]">Referencia {i + 1}</p>
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B]">
|
||||||
|
Referencia {i + 1}
|
||||||
|
</p>
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
aria-label={`Eliminar referencia ${i + 1}`}
|
||||||
|
title="Eliminar referencia"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full border border-red-200 text-sm leading-none text-red-600 transition-colors duration-100 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<CampoTexto nombre={`referencias.${i}.nombre`} etiqueta="Nombre completo" />
|
<CampoTexto nombre={`referencias.${i}.nombre`} etiqueta="Nombre completo" />
|
||||||
<CampoTexto nombre={`referencias.${i}.telefono`} etiqueta="Teléfono" />
|
<CampoTexto nombre={`referencias.${i}.telefono`} etiqueta="Teléfono" />
|
||||||
@@ -41,6 +61,17 @@ export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: Pa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div className="md:col-span-2 lg:col-span-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => append(referenciaVacia())}
|
||||||
|
disabled={fields.length >= MAX_REFERENCIAS}
|
||||||
|
title={fields.length >= MAX_REFERENCIAS ? `Máximo ${MAX_REFERENCIAS} referencias` : undefined}
|
||||||
|
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent transition-colors duration-100 hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
+ Agregar referencia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
{errorRaiz && typeof errorRaiz.message === 'string' && (
|
{errorRaiz && typeof errorRaiz.message === 'string' && (
|
||||||
<p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
|
<p className="mt-2 px-6 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { AltaCliente } from '../types'
|
import type { AltaCliente } from '../types'
|
||||||
import { fechaISOHoy } from '../lib/formatters'
|
import { fechaISOHoy } from '../lib/formatters'
|
||||||
import { ASESOR_DEFAULT, AUTORIDAD_ID_DEFAULT } from '../data/constantes'
|
import { asesorDeSesion, AUTORIDAD_ID_DEFAULT } from '../data/constantes'
|
||||||
import { useDocumentos } from './useDocumentos'
|
import { useDocumentos } from './useDocumentos'
|
||||||
|
|
||||||
const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({
|
const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({
|
||||||
@@ -56,10 +56,18 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
|
|||||||
dependientes: { personas: '', parentesco: '', edad: '' },
|
dependientes: { personas: '', parentesco: '', edad: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const referenciaVacia = (): AltaCliente['referencias'][number] => ({
|
||||||
|
nombre: '',
|
||||||
|
telefono: '',
|
||||||
|
parentesco: '',
|
||||||
|
direccion: '',
|
||||||
|
})
|
||||||
|
|
||||||
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||||
leadMondayId: null,
|
leadMondayId: null,
|
||||||
apartadoMondayId: null,
|
apartadoMondayId: null,
|
||||||
progresoId: null,
|
progresoId: null,
|
||||||
|
foto: null,
|
||||||
datosGenerales: datosGeneralesVacios(),
|
datosGenerales: datosGeneralesVacios(),
|
||||||
empleo: empleoVacio(),
|
empleo: empleoVacio(),
|
||||||
economicos: economicosVacios(),
|
economicos: economicosVacios(),
|
||||||
@@ -69,12 +77,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
|||||||
empleo: empleoVacio(),
|
empleo: empleoVacio(),
|
||||||
economicos: economicosVacios(),
|
economicos: economicosVacios(),
|
||||||
},
|
},
|
||||||
referencias: [
|
referencias: [referenciaVacia()],
|
||||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
|
||||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
|
||||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
|
||||||
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
|
|
||||||
],
|
|
||||||
viviendaCredito: {
|
viviendaCredito: {
|
||||||
proyecto: '',
|
proyecto: '',
|
||||||
proyectoOtro: '',
|
proyectoOtro: '',
|
||||||
@@ -107,7 +110,7 @@ const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
|||||||
pais: 'MEXICO',
|
pais: 'MEXICO',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
firma: { firmaDataUrl: '', asesorNombre: ASESOR_DEFAULT },
|
firma: { firmaDataUrl: '', asesorNombre: asesorDeSesion() },
|
||||||
})
|
})
|
||||||
|
|
||||||
export const PASOS = [
|
export const PASOS = [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const fixtureAlta: AltaCliente = {
|
|||||||
leadMondayId: null,
|
leadMondayId: null,
|
||||||
apartadoMondayId: null,
|
apartadoMondayId: null,
|
||||||
progresoId: null,
|
progresoId: null,
|
||||||
|
foto: null,
|
||||||
datosGenerales: {
|
datosGenerales: {
|
||||||
campaniaVenta: '',
|
campaniaVenta: '',
|
||||||
fecha: '2026-07-19',
|
fecha: '2026-07-19',
|
||||||
|
|||||||
@@ -170,4 +170,17 @@ describe('referenciasSchema', () => {
|
|||||||
])
|
])
|
||||||
expect(r.success).toBe(true)
|
expect(r.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('acepta un solo bloque de referencia (arreglo dinámico, ya no exige 4)', () => {
|
||||||
|
const r = referenciasSchema.safeParse([
|
||||||
|
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
|
||||||
|
])
|
||||||
|
expect(r.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza más de 4 referencias', () => {
|
||||||
|
const valida = { nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: '', direccion: '' }
|
||||||
|
const r = referenciasSchema.safeParse([valida, valida, valida, valida, valida])
|
||||||
|
expect(r.success).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ const referenciaSchema = z.object({
|
|||||||
|
|
||||||
export const referenciasSchema = z
|
export const referenciasSchema = z
|
||||||
.array(referenciaSchema)
|
.array(referenciaSchema)
|
||||||
.length(4, 'Se capturan 4 referencias')
|
.min(1, 'Captura al menos 1 referencia con nombre y teléfono')
|
||||||
|
.max(4, 'Máximo 4 referencias')
|
||||||
.refine(
|
.refine(
|
||||||
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
|
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
|
||||||
'Captura al menos 1 referencia con nombre y teléfono',
|
'Captura al menos 1 referencia con nombre y teléfono',
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
|
|||||||
),
|
),
|
||||||
).toHaveLength(0)
|
).toHaveLength(0)
|
||||||
// El flujo posterior queda idéntico: alta registrada con el id del progreso
|
// El flujo posterior queda idéntico: alta registrada con el id del progreso
|
||||||
expect(await screen.findByText(/Alta registrada en el servidor: prog-1/)).toBeTruthy()
|
expect(await screen.findByText(/Alta registrada correctamente/)).toBeTruthy()
|
||||||
|
expect(screen.getByText(/Folio prog-1/)).toBeTruthy()
|
||||||
|
// Pantalla de éxito con las acciones post-registro (sin redirect automático)
|
||||||
|
expect(screen.getByRole('button', { name: 'Generar liga de firma' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Ir a Mi día' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Registrar otro cliente' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -226,9 +226,9 @@ export default function AltaClientePage() {
|
|||||||
setPantalla('wizard')
|
setPantalla('wizard')
|
||||||
}
|
}
|
||||||
|
|
||||||
const guardar = async () => {
|
const guardar = async (pasoDestino: number = paso) => {
|
||||||
try {
|
try {
|
||||||
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
|
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos })
|
||||||
toast.success("Borrador guardado")
|
toast.success("Borrador guardado")
|
||||||
setBorradorId(b.id)
|
setBorradorId(b.id)
|
||||||
setAvisoBorrador(
|
setAvisoBorrador(
|
||||||
@@ -239,6 +239,49 @@ export default function AltaClientePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Guard de cambios sin guardar (saltos con el Stepper) ----------
|
||||||
|
// El formulario del paso guarda sus capturas solo al avanzar con "Siguiente";
|
||||||
|
// si el usuario salta a otro paso con el Stepper, se le ofrece guardar antes.
|
||||||
|
const [sucio, setSucio] = useState(false)
|
||||||
|
// Paso elegido en el Stepper mientras el confirm de cambios sin guardar está abierto
|
||||||
|
const [destinoPendiente, setDestinoPendiente] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Al cambiar de paso el formulario se desmonta y remonta: se parte de cero
|
||||||
|
useEffect(() => {
|
||||||
|
setSucio(false)
|
||||||
|
}, [paso])
|
||||||
|
|
||||||
|
/** Cualquier edición dentro del paso actual marca cambios sin guardar */
|
||||||
|
const marcarSucio = () => setSucio(true)
|
||||||
|
|
||||||
|
/** Cambio de paso vía Stepper: confirma antes de saltar si hay cambios sin guardar */
|
||||||
|
const irAPasoConGuard = (destino: number) => {
|
||||||
|
if (destino === paso) return
|
||||||
|
if (sucio) {
|
||||||
|
setDestinoPendiente(destino)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
irAPaso(destino)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Guardar": persiste el borrador apuntando al paso destino y continúa hacia él */
|
||||||
|
const guardarYContinuar = async () => {
|
||||||
|
const destino = destinoPendiente
|
||||||
|
if (destino === null) return
|
||||||
|
setDestinoPendiente(null)
|
||||||
|
await guardar(destino)
|
||||||
|
irAPaso(destino)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Continuar sin guardar": descarta lo capturado en el paso y salta al destino */
|
||||||
|
const continuarSinGuardar = () => {
|
||||||
|
const destino = destinoPendiente
|
||||||
|
if (destino === null) return
|
||||||
|
setDestinoPendiente(null)
|
||||||
|
setSucio(false)
|
||||||
|
irAPaso(destino)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Guardado progresivo (borrador local + Contacto en Monday) ----------
|
// ---------- Guardado progresivo (borrador local + Contacto en Monday) ----------
|
||||||
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
|
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
|
||||||
// avance manda el estado completo (el PATCH es idempotente).
|
// avance manda el estado completo (el PATCH es idempotente).
|
||||||
@@ -309,14 +352,20 @@ export default function AltaClientePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirigir a Mi día después de completar el alta
|
// Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
|
||||||
useEffect(() => {
|
const registrarOtro = () => {
|
||||||
if (!registro) return
|
setRegistro('')
|
||||||
const timer = setTimeout(() => {
|
setSinConexion(false)
|
||||||
window.location.href = '/hoy/'
|
setErrorValidacion('')
|
||||||
}, 2000)
|
setLigaUrl('')
|
||||||
return () => clearTimeout(timer)
|
setLigaCopiada(false)
|
||||||
}, [registro])
|
setErrorLiga('')
|
||||||
|
empezarNuevo()
|
||||||
|
}
|
||||||
|
|
||||||
|
const irAMiDia = () => {
|
||||||
|
window.location.href = '/hoy/'
|
||||||
|
}
|
||||||
|
|
||||||
// Genera la liga pública y copia al portapapeles el texto listo para WhatsApp
|
// Genera la liga pública y copia al portapapeles el texto listo para WhatsApp
|
||||||
const generarLiga = async () => {
|
const generarLiga = async () => {
|
||||||
@@ -485,6 +534,77 @@ export default function AltaClientePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Pantalla de éxito: el alta quedó registrada en el servidor ----------
|
||||||
|
if (registro) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||||
|
<AppHeader titulo="Alta de Clientes" />
|
||||||
|
<main className="px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<div className="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-900 dark:bg-green-950/40">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-green-100 text-lg text-green-700 dark:bg-green-900 dark:text-green-400"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-lg font-semibold text-green-800 dark:text-green-300">
|
||||||
|
Alta registrada correctamente
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-green-700 dark:text-green-400">
|
||||||
|
{alta.datosGenerales.nombreCompleto || 'Cliente'} · Folio {registro}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generarLiga()}
|
||||||
|
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Generar liga de firma
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={irAMiDia}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
Ir a Mi día
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={registrarOtro}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
Registrar otro cliente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
|
||||||
|
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
|
||||||
|
celular (vigencia 72 h, sin usuario ni contraseña).
|
||||||
|
</p>
|
||||||
|
{ligaUrl && (
|
||||||
|
<p className="mt-3 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{ligaUrl}
|
||||||
|
{ligaCopiada && (
|
||||||
|
<span role="status" className="ml-2 font-medium text-accent">
|
||||||
|
Liga y mensaje de WhatsApp copiados
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||||
<AppHeader
|
<AppHeader
|
||||||
@@ -515,10 +635,10 @@ export default function AltaClientePage() {
|
|||||||
<main className="px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
<main className="px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||||
<div className="md:grid md:grid-cols-[260px_minmax(0,1fr)] md:gap-8 lg:gap-8">
|
<div className="md:grid md:grid-cols-[260px_minmax(0,1fr)] md:gap-8 lg:gap-8">
|
||||||
<div className="mb-6 md:sticky md:top-20 md:mb-0 md:self-start">
|
<div className="mb-6 md:sticky md:top-20 md:mb-0 md:self-start">
|
||||||
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
|
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPasoConGuard} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-3xl">
|
<div className="w-full max-w-3xl" onInput={marcarSucio} onChange={marcarSucio}>
|
||||||
{errorCarga && (
|
{errorCarga && (
|
||||||
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
|
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
|
||||||
{errorCarga}
|
{errorCarga}
|
||||||
@@ -549,9 +669,10 @@ export default function AltaClientePage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<FotoCliente
|
<FotoCliente
|
||||||
foto={null}
|
foto={datos.foto}
|
||||||
onFoto={() => {
|
onFoto={(foto) => {
|
||||||
// Foto capturada (el componente gestiona el preview internamente)
|
actualizar('foto', foto || null)
|
||||||
|
marcarSucio()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -662,16 +783,6 @@ export default function AltaClientePage() {
|
|||||||
)}
|
)}
|
||||||
{paso === 7 && (
|
{paso === 7 && (
|
||||||
<>
|
<>
|
||||||
{registro && (
|
|
||||||
<p className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
|
|
||||||
Alta registrada en el servidor: {registro}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{registro && (
|
|
||||||
<p className="mb-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Redirigiendo a Mi día en unos segundos…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{sinConexion && (
|
{sinConexion && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -688,35 +799,6 @@ export default function AltaClientePage() {
|
|||||||
El servidor rechazó el alta: {errorValidacion}
|
El servidor rechazó el alta: {errorValidacion}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{registro && (
|
|
||||||
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Firma en remoto
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus
|
|
||||||
documentos desde su celular, sin usuario ni contraseña.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void generarLiga()}
|
|
||||||
className="mt-4 rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
Generar liga para firmar en remoto
|
|
||||||
</button>
|
|
||||||
{ligaUrl && (
|
|
||||||
<p className="mt-4 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
|
|
||||||
{ligaUrl}
|
|
||||||
{ligaCopiada && (
|
|
||||||
<span role="status" className="ml-2 font-medium text-accent">
|
|
||||||
Liga y mensaje de WhatsApp copiados
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TarjetaPaso>
|
<TarjetaPaso>
|
||||||
<FirmasForm
|
<FirmasForm
|
||||||
alta={alta}
|
alta={alta}
|
||||||
@@ -729,6 +811,44 @@ export default function AltaClientePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Confirmación de cambios sin guardar al saltar de paso con el Stepper */}
|
||||||
|
{destinoPendiente !== null && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-900/50 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="guard-cambios-titulo"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||||
|
<h2
|
||||||
|
id="guard-cambios-titulo"
|
||||||
|
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
||||||
|
>
|
||||||
|
Cambios sin guardar
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
|
||||||
|
¿Deseas guardar antes de continuar?
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={continuarSinGuardar}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
Continuar sin guardar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void guardarYContinuar()}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ export interface AltaCliente {
|
|||||||
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
|
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
|
||||||
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
||||||
progresoId: string | null
|
progresoId: string | null
|
||||||
|
// Foto del cliente (data URL base64) capturada en el paso de datos generales;
|
||||||
|
// null cuando aún no se toma. Viaja en el payload del alta y en los borradores.
|
||||||
|
foto: string | null
|
||||||
datosGenerales: DatosGenerales
|
datosGenerales: DatosGenerales
|
||||||
empleo: DatosEmpleo
|
empleo: DatosEmpleo
|
||||||
economicos: DatosEconomicos
|
economicos: DatosEconomicos
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { request } from '../services/api'
|
import { request } from '../services/api'
|
||||||
import { formatoMoneda } from '../lib/formatters'
|
import { formatoMoneda } from '../lib/formatters'
|
||||||
|
import { telefonoParaEnlace } from '../lib/cobranza'
|
||||||
import KpiCard from './KpiCard'
|
import KpiCard from './KpiCard'
|
||||||
|
|
||||||
/** Registro de cliente moroso del endpoint GET /api/reportes/mora */
|
/** Registro de cliente moroso del endpoint GET /api/reportes/mora (mora.service) */
|
||||||
export interface ClienteMora {
|
export interface ClienteMora {
|
||||||
clienteId: string
|
|
||||||
clienteNombre: string
|
clienteNombre: string
|
||||||
clienteTelefono: string
|
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
proyecto: string
|
proyecto: string
|
||||||
pagoVencido: number // ISO fecha
|
pagoVencido: number // número de pago vencido
|
||||||
monto: number
|
monto: number
|
||||||
diasAtraso: number
|
diasAtraso: number
|
||||||
|
telefono: string | null
|
||||||
|
planId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MoraResponse {
|
interface MoraResponse {
|
||||||
totalMorosos: number
|
clientesConMora: number
|
||||||
montoTotalVencido: number
|
montoTotalVencido: number
|
||||||
diasPromedio: number
|
diasPromedioAtraso: number
|
||||||
clientes: ClienteMoria[]
|
clientes: ClienteMora[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normaliza la respuesta del API; acepta tanto el shape del API como el local */
|
|
||||||
type ClienteMoria = ClienteMora
|
|
||||||
|
|
||||||
function abrirWhatsApp(telefono: string) {
|
function abrirWhatsApp(telefono: string) {
|
||||||
const limpio = telefono.replace(/[^0-9+]/g, '')
|
// Misma normalización +52 que mi-dia: 10 dígitos → México
|
||||||
window.open(`https://wa.me/${limpio.startsWith('+') ? limpio.slice(1) : limpio}`, '_blank')
|
window.open(`https://wa.me/${telefonoParaEnlace(telefono)}`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MoraDashboard() {
|
export default function MoraDashboard() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [datos, setDatos] = useState<MoraResponse | null>(null)
|
const [datos, setDatos] = useState<MoraResponse | null>(null)
|
||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -80,9 +80,9 @@ export default function MoraDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<KpiCard titulo="Total morosos" valor={datos.totalMorosos} />
|
<KpiCard titulo="Total morosos" valor={datos.clientesConMora} />
|
||||||
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
|
<KpiCard titulo="Monto vencido" valor={formatoMoneda(datos.montoTotalVencido)} />
|
||||||
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedio} />
|
<KpiCard titulo="Días promedio atraso" valor={datos.diasPromedioAtraso} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
|
<div className="overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
@@ -100,7 +100,7 @@ export default function MoraDashboard() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#E5E7EB]">
|
<tbody className="divide-y divide-[#E5E7EB]">
|
||||||
{datos.clientes.map((c) => (
|
{datos.clientes.map((c) => (
|
||||||
<tr key={c.clienteId} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
|
<tr key={`${c.planId}-${c.pagoVencido}`} className="transition-colors hover:bg-[#F8FAFC] dark:hover:bg-[#0F172A]">
|
||||||
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
|
<td className="px-4 py-3 font-medium text-[#111827] dark:text-[#E2E8F0]">{c.clienteNombre}</td>
|
||||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
|
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.unidadCodigo}</td>
|
||||||
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
|
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{c.proyecto}</td>
|
||||||
@@ -114,15 +114,25 @@ export default function MoraDashboard() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{c.clienteTelefono && (
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => abrirWhatsApp(c.clienteTelefono)}
|
onClick={() => navigate(`/planes/${c.planId}`)}
|
||||||
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
|
className="text-xs font-medium text-accent hover:underline"
|
||||||
|
aria-label={`Ver plan de ${c.clienteNombre}`}
|
||||||
>
|
>
|
||||||
WhatsApp
|
Ver plan →
|
||||||
</button>
|
</button>
|
||||||
)}
|
{c.telefono && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => c.telefono && abrirWhatsApp(c.telefono)}
|
||||||
|
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-green-700"
|
||||||
|
>
|
||||||
|
WhatsApp
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -183,6 +183,16 @@ export interface CobranzaItem {
|
|||||||
atraso: number
|
atraso: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza un teléfono a dígitos para enlaces wa.me. Si el número no trae
|
||||||
|
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
|
||||||
|
*/
|
||||||
|
export function telefonoParaEnlace(telefono: string): string {
|
||||||
|
const digitos = telefono.replace(/\D/g, '')
|
||||||
|
if (digitos.length === 10) return `52${digitos}`
|
||||||
|
return digitos
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
|
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
|
||||||
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ export default function DashboardPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
<tr className="border-b border-[#E5E7EB] bg-[#F8FAFC] text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
||||||
<th scope="col" className="px-4 py-3">Cliente</th>
|
<th scope="col" className="px-4 py-3">Cliente</th>
|
||||||
|
<th scope="col" className="px-4 py-3">Teléfono</th>
|
||||||
<th scope="col" className="px-4 py-3">Unidad</th>
|
<th scope="col" className="px-4 py-3">Unidad</th>
|
||||||
<th scope="col" className="px-4 py-3">Proyecto</th>
|
<th scope="col" className="px-4 py-3">Proyecto</th>
|
||||||
{encabezadoOrdenable('avance', '% pagado')}
|
{encabezadoOrdenable('avance', '% pagado')}
|
||||||
@@ -387,6 +388,7 @@ export default function DashboardPage() {
|
|||||||
{d.plan.clienteNombre}
|
{d.plan.clienteNombre}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{d.plan.clienteTelefono || '—'}</td>
|
||||||
<td className="px-4 py-3">{d.plan.unidadCodigo}</td>
|
<td className="px-4 py-3">{d.plan.unidadCodigo}</td>
|
||||||
<td className="px-4 py-3">{d.plan.proyecto}</td>
|
<td className="px-4 py-3">{d.plan.proyecto}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -414,7 +416,7 @@ export default function DashboardPage() {
|
|||||||
))}
|
))}
|
||||||
{ordenados.length === 0 && (
|
{ordenados.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-8 text-center text-neutral-500">
|
<td colSpan={9} className="py-8 text-center text-neutral-500">
|
||||||
No hay planes que coincidan con el filtro.
|
No hay planes que coincidan con el filtro.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
diasAtraso,
|
diasAtraso,
|
||||||
moraDePago,
|
moraDePago,
|
||||||
pagadoTotal,
|
pagadoTotal,
|
||||||
|
proximoVencimiento,
|
||||||
round2,
|
round2,
|
||||||
saldoPlan,
|
saldoPlan,
|
||||||
statusPago,
|
statusPago,
|
||||||
@@ -136,22 +137,43 @@ export default function PlanDetallePage() {
|
|||||||
|
|
||||||
const avance = avancePlan(plan)
|
const avance = avancePlan(plan)
|
||||||
|
|
||||||
|
/** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */
|
||||||
|
const proximoPago = status === 'ACTIVO' ? proximoVencimiento(plan, hoy) : null
|
||||||
|
|
||||||
|
/** Abonar al próximo pago: hace scroll a su fila y abre el modal prellenado */
|
||||||
|
const abonarProximoPago = (pago: Pago) => {
|
||||||
|
document.getElementById(`pago-${pago.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
setPagoAbono(pago)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<Link to="/" className="text-sm text-accent hover:underline">
|
<div>
|
||||||
← Volver al dashboard
|
<Link to="/" className="text-sm text-accent hover:underline">
|
||||||
</Link>
|
← Volver al dashboard
|
||||||
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
|
</Link>
|
||||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
|
||||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
|
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span>
|
||||||
</p>
|
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
</p>
|
||||||
Tel. {plan.clienteTelefono || '—'}
|
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
Tel. {plan.clienteTelefono || '—'}
|
||||||
</p>
|
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{proximoPago && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => abonarProximoPago(proximoPago)}
|
||||||
|
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||||
|
aria-label="Registrar abono al próximo pago no cubierto"
|
||||||
|
>
|
||||||
|
Abonar al próximo pago
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{offline && (
|
{offline && (
|
||||||
@@ -245,7 +267,7 @@ export default function PlanDetallePage() {
|
|||||||
const expandido = expandidos.has(pago.id)
|
const expandido = expandidos.has(pago.id)
|
||||||
return (
|
return (
|
||||||
<Fragment key={pago.id}>
|
<Fragment key={pago.id}>
|
||||||
<tr className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
<tr id={`pago-${pago.id}`} className="transition-colors duration-100 ease-out hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||||
<td className="px-4 py-3 font-medium">{pago.numPago}</td>
|
<td className="px-4 py-3 font-medium">{pago.numPago}</td>
|
||||||
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
<td className="px-4 py-3">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||||
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
<td className="px-4 py-3 text-right">{formatoMoneda(pago.monto)}</td>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
type StorageLike,
|
type StorageLike,
|
||||||
} from './repository'
|
} from './repository'
|
||||||
import { ErrorApi } from './api'
|
import { ErrorApi } from './api'
|
||||||
|
|
||||||
|
// Misma resolución de base que api.ts: el test no depende de la VITE_API_URL local
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||||
import type { PlanPago } from '../types'
|
import type { PlanPago } from '../types'
|
||||||
|
|
||||||
/** Stub sencillo de localStorage para correr en entorno node */
|
/** Stub sencillo de localStorage para correr en entorno node */
|
||||||
@@ -120,7 +123,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
|
|||||||
expect(plan.tasaMoraMensual).toBeUndefined()
|
expect(plan.tasaMoraMensual).toBeUndefined()
|
||||||
expect(plan).not.toHaveProperty('saldo')
|
expect(plan).not.toHaveProperty('saldo')
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'http://192.168.10.123:3000/api/planes',
|
`${API_URL}/planes`,
|
||||||
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
|
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -205,7 +208,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
|
|||||||
expect(r.plan.id).toBe('api-nuevo')
|
expect(r.plan.id).toBe('api-nuevo')
|
||||||
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
|
expect(storage.getItem(CLAVE_STORAGE)).toBeNull()
|
||||||
const [url, init] = fetchMock.mock.calls[0]
|
const [url, init] = fetchMock.mock.calls[0]
|
||||||
expect(url).toBe('http://192.168.10.123:3000/api/planes')
|
expect(url).toBe(`${API_URL}/planes`)
|
||||||
expect(init?.method).toBe('POST')
|
expect(init?.method).toBe('POST')
|
||||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||||
expect(body.clienteNombre).toBe('Cliente Prueba')
|
expect(body.clienteNombre).toBe('Cliente Prueba')
|
||||||
@@ -239,7 +242,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
|
|||||||
|
|
||||||
expect(r.origen).toBe('api')
|
expect(r.origen).toBe('api')
|
||||||
const [url, init] = fetchMock.mock.calls[0]
|
const [url, init] = fetchMock.mock.calls[0]
|
||||||
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/abonos')
|
expect(url).toBe(`${API_URL}/planes/api-1/abonos`)
|
||||||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||||
expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' })
|
expect(body).toEqual({ pagoId: 'pago-1', fechaPago: '2026-09-02', monto: 10000, referencia: 'SPEI-1' })
|
||||||
})
|
})
|
||||||
@@ -291,7 +294,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
|
|||||||
texto: 'Se habló con el cliente',
|
texto: 'Se habló con el cliente',
|
||||||
})
|
})
|
||||||
const [url] = fetchMock.mock.calls[0]
|
const [url] = fetchMock.mock.calls[0]
|
||||||
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/notas')
|
expect(url).toBe(`${API_URL}/planes/api-1/notas`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => {
|
it('agregarNotaAsync cae a localStorage cuando falla la red (conserva la validación local)', async () => {
|
||||||
@@ -317,7 +320,7 @@ describe('repository async (API primero, fallback localStorage)', () => {
|
|||||||
expect(r.origen).toBe('api')
|
expect(r.origen).toBe('api')
|
||||||
expect(r.plan.status).toBe('CANCELADO')
|
expect(r.plan.status).toBe('CANCELADO')
|
||||||
const [url, init] = fetchMock.mock.calls[0]
|
const [url, init] = fetchMock.mock.calls[0]
|
||||||
expect(url).toBe('http://192.168.10.123:3000/api/planes/api-1/cancelar')
|
expect(url).toBe(`${API_URL}/planes/api-1/cancelar`)
|
||||||
expect(init?.method).toBe('POST')
|
expect(init?.method).toBe('POST')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -361,7 +364,7 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
|
|||||||
|
|
||||||
expect(r.origen).toBe('api')
|
expect(r.origen).toBe('api')
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'http://192.168.10.123:3000/api/planes?mios=1',
|
`${API_URL}/planes?mios=1`,
|
||||||
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
|
expect.objectContaining({ headers: { 'Content-Type': 'application/json' } }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -107,12 +107,15 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
|||||||
>
|
>
|
||||||
Retomar
|
Retomar
|
||||||
</button>
|
</button>
|
||||||
<a
|
{/* El alta prellenada requiere la cotización en el servidor; las locales (CTE-LOCAL-*) no la tienen */}
|
||||||
href={`/client/?cotizacion=${c.id}`}
|
{!c.id.startsWith('local-') && (
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
<a
|
||||||
>
|
href={`/client/?cotizacion=${c.id}`}
|
||||||
Iniciar alta
|
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
||||||
</a>
|
>
|
||||||
|
Iniciar alta
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -55,6 +55,30 @@ describe('crearDocumentoPdf', () => {
|
|||||||
})
|
})
|
||||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
|
||||||
|
const unidad = UNIDADES_SEED[0]
|
||||||
|
const propuestas = calcularPropuestas({
|
||||||
|
unidad,
|
||||||
|
acabados: true,
|
||||||
|
apartado: APARTADO_DEFAULT,
|
||||||
|
descuentos: { ...DESCUENTOS_DEFAULT },
|
||||||
|
numMeses: 14,
|
||||||
|
})
|
||||||
|
const { doc } = crearDocumentoPdf({
|
||||||
|
unidad,
|
||||||
|
acabados: true,
|
||||||
|
apartado: APARTADO_DEFAULT,
|
||||||
|
precioLista: unidad.precioConAcabados,
|
||||||
|
propuestas,
|
||||||
|
cliente: { nombre: 'Juan Pérez' },
|
||||||
|
folio: 'CTE-LOCAL-1757568000000',
|
||||||
|
})
|
||||||
|
// El folio local viaja en el contenido del PDF aunque no se haya guardado en el API
|
||||||
|
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
|
||||||
|
expect(pdf).toContain('CTE-LOCAL-1757568000000')
|
||||||
|
expect(pdf).toContain('FOLIO')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('cuota de mantenimiento', () => {
|
describe('cuota de mantenimiento', () => {
|
||||||
|
|||||||
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 { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
@@ -21,6 +21,7 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
|||||||
import { calcularVigencia } from '../lib/vigencia'
|
import { calcularVigencia } from '../lib/vigencia'
|
||||||
import { folioCotizacion } from '../lib/folio'
|
import { folioCotizacion } from '../lib/folio'
|
||||||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||||||
|
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
|
||||||
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
||||||
import {
|
import {
|
||||||
getMisCotizaciones,
|
getMisCotizaciones,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
TIPO_DE_API,
|
TIPO_DE_API,
|
||||||
type CotizacionGuardada,
|
type CotizacionGuardada,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
|
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
|
||||||
import type { Cliente } from '../types'
|
import type { Cliente } from '../types'
|
||||||
|
|
||||||
const esquemaCliente = z.object({
|
const esquemaCliente = z.object({
|
||||||
@@ -87,6 +89,8 @@ export default function CotizadorPage() {
|
|||||||
const [errorMias, setErrorMias] = useState<string | null>(null)
|
const [errorMias, setErrorMias] = useState<string | null>(null)
|
||||||
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
|
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
|
||||||
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
|
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
|
||||||
|
// true cuando el modal de cliente se abrió desde "Guardar" (sin generar PDF)
|
||||||
|
const [guardarSinPdf, setGuardarSinPdf] = useState(false)
|
||||||
const [guardando, setGuardando] = useState(false)
|
const [guardando, setGuardando] = useState(false)
|
||||||
const [aviso, setAviso] = useState<string | null>(null)
|
const [aviso, setAviso] = useState<string | null>(null)
|
||||||
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
|
// Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda
|
||||||
@@ -96,9 +100,22 @@ export default function CotizadorPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let vivo = true
|
let vivo = true
|
||||||
|
/** Combina servidor + locales, sin duplicados, más recientes primero */
|
||||||
|
const mezclar = (principal: CotizacionGuardada[], extra: CotizacionGuardada[]) => {
|
||||||
|
const ids = new Set(principal.map((c) => c.id))
|
||||||
|
const combinadas = [...principal, ...extra.filter((c) => !ids.has(c.id))]
|
||||||
|
return combinadas
|
||||||
|
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||||
|
.slice(0, 50)
|
||||||
|
}
|
||||||
|
// Cotizaciones guardadas localmente cuando el API no respondió
|
||||||
|
void cargarCotizacionesLocales().then((locales) => {
|
||||||
|
if (!vivo || locales.length === 0) return
|
||||||
|
setMisCotizaciones((prev) => mezclar(prev, locales as CotizacionGuardada[]))
|
||||||
|
})
|
||||||
getMisCotizaciones()
|
getMisCotizaciones()
|
||||||
.then((lista) => {
|
.then((lista) => {
|
||||||
if (vivo) setMisCotizaciones(lista)
|
if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev))
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
|
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
|
||||||
@@ -162,9 +179,63 @@ export default function CotizadorPage() {
|
|||||||
|
|
||||||
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
||||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
||||||
|
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
||||||
|
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
|
||||||
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
|
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando
|
||||||
|
|
||||||
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */
|
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
|
||||||
|
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
|
||||||
|
// más barata; la elección manual del vendedor se respeta hasta el siguiente
|
||||||
|
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
|
||||||
|
const unidadPrevia = useRef<string | null | undefined>(undefined)
|
||||||
|
const saltarAuto = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const codigo = unidad?.codigo ?? null
|
||||||
|
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
|
||||||
|
unidadPrevia.current = codigo
|
||||||
|
if (!cambioUnidad || saltarAuto.current) {
|
||||||
|
saltarAuto.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
|
||||||
|
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
|
||||||
|
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
|
||||||
|
}, [unidad?.codigo, propuestas, apartadoEfectivo])
|
||||||
|
|
||||||
|
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
|
||||||
|
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
|
||||||
|
|
||||||
|
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
|
||||||
|
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
return {
|
||||||
|
id: `local-${timestamp}`,
|
||||||
|
folio: `CTE-LOCAL-${timestamp}`,
|
||||||
|
unidadCodigo: unidad!.codigo,
|
||||||
|
desarrollo,
|
||||||
|
clienteNombre: cliente.nombre,
|
||||||
|
clienteTelefono: cliente.telefono ?? null,
|
||||||
|
clienteEmail: cliente.email ?? null,
|
||||||
|
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
|
||||||
|
acabados,
|
||||||
|
apartado: apartadoEfectivo,
|
||||||
|
descuento: propuestaActiva.descuento,
|
||||||
|
precioLista,
|
||||||
|
precioFinal: propuestaActiva.precioFinal,
|
||||||
|
enganche: propuestaActiva.enganche,
|
||||||
|
numMensualidades: propuestaActiva.numMensualidades ?? 0,
|
||||||
|
mensualidad: propuestaActiva.mensualidad,
|
||||||
|
pagoFinal: propuestaActiva.pagoFinal,
|
||||||
|
createdAt: new Date(timestamp).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persiste la cotización en el API. Si no responde, la guarda localmente
|
||||||
|
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
|
||||||
|
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
|
||||||
|
*/
|
||||||
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
||||||
if (!unidad || !propuestaActiva) return null
|
if (!unidad || !propuestaActiva) return null
|
||||||
try {
|
try {
|
||||||
@@ -186,9 +257,14 @@ export default function CotizadorPage() {
|
|||||||
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
|
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
|
||||||
return conFolio
|
return conFolio
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('No se pudo guardar la cotización'); console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err)
|
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
|
||||||
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).')
|
const local = cotizacionLocal(cliente)
|
||||||
return null
|
void guardarCotizacionLocal(local)
|
||||||
|
setUltimaGuardada(local)
|
||||||
|
setMisCotizaciones((prev) => [local, ...prev].slice(0, 50))
|
||||||
|
toast('Cotización guardada en este dispositivo (sin conexión al servidor)')
|
||||||
|
setAviso(`Sin conexión al servidor: cotización guardada localmente con folio ${local.folio}.`)
|
||||||
|
return local
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,10 +277,13 @@ export default function CotizadorPage() {
|
|||||||
let qrDataUrl: string | undefined
|
let qrDataUrl: string | undefined
|
||||||
if (guardada) {
|
if (guardada) {
|
||||||
folio = guardada.folio ?? folioCotizacion(guardada.id)
|
folio = guardada.folio ?? folioCotizacion(guardada.id)
|
||||||
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
|
||||||
margin: 1,
|
if (!esCotizacionLocal(guardada)) {
|
||||||
width: 200,
|
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
||||||
})
|
margin: 1,
|
||||||
|
width: 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
generarPdf({
|
generarPdf({
|
||||||
unidad,
|
unidad,
|
||||||
@@ -227,6 +306,7 @@ export default function CotizadorPage() {
|
|||||||
if (!unidad) return
|
if (!unidad) return
|
||||||
// Sin nombre capturado, pide los datos del cliente en el modal
|
// Sin nombre capturado, pide los datos del cliente en el modal
|
||||||
if (nombreCliente.trim().length < 3) {
|
if (nombreCliente.trim().length < 3) {
|
||||||
|
setGuardarSinPdf(false)
|
||||||
setModalClienteAbierto(true)
|
setModalClienteAbierto(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -237,12 +317,42 @@ export default function CotizadorPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Guarda solo (sin PDF); con el flujo de modal si falta el nombre del cliente */
|
||||||
|
const guardarSolo = async (cliente: Cliente) => {
|
||||||
|
if (!unidad) return
|
||||||
|
setGuardando(true)
|
||||||
|
try {
|
||||||
|
await guardarEnApi(cliente)
|
||||||
|
} finally {
|
||||||
|
setGuardando(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGuardar = () => {
|
||||||
|
if (!unidad) return
|
||||||
|
if (nombreCliente.trim().length < 3) {
|
||||||
|
setGuardarSinPdf(true)
|
||||||
|
setModalClienteAbierto(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void guardarSolo({
|
||||||
|
nombre: nombreCliente.trim(),
|
||||||
|
email: watch('email') || undefined,
|
||||||
|
telefono: watch('telefono') || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleModalAceptar = (cliente: Cliente) => {
|
const handleModalAceptar = (cliente: Cliente) => {
|
||||||
setModalClienteAbierto(false)
|
setModalClienteAbierto(false)
|
||||||
setValue('nombre', cliente.nombre, { shouldValidate: true })
|
setValue('nombre', cliente.nombre, { shouldValidate: true })
|
||||||
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
|
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
|
||||||
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
|
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
|
||||||
void generarPdfConCliente(cliente)
|
if (guardarSinPdf) {
|
||||||
|
setGuardarSinPdf(false)
|
||||||
|
void guardarSolo(cliente)
|
||||||
|
} else {
|
||||||
|
void generarPdfConCliente(cliente)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCompartir = async () => {
|
const handleCompartir = async () => {
|
||||||
@@ -256,7 +366,10 @@ export default function CotizadorPage() {
|
|||||||
...(ultimaGuardada
|
...(ultimaGuardada
|
||||||
? {
|
? {
|
||||||
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
|
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
|
||||||
urlPublica: urlPublicaCotizacion(ultimaGuardada.id),
|
// La URL pública solo existe si la cotización vive en el servidor
|
||||||
|
...(esCotizacionLocal(ultimaGuardada)
|
||||||
|
? {}
|
||||||
|
: { urlPublica: urlPublicaCotizacion(ultimaGuardada.id) }),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
})
|
})
|
||||||
@@ -281,6 +394,7 @@ export default function CotizadorPage() {
|
|||||||
}
|
}
|
||||||
const tipo = TIPO_DE_API[c.tipoPropuesta]
|
const tipo = TIPO_DE_API[c.tipoPropuesta]
|
||||||
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
|
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
|
||||||
|
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
|
||||||
restaurar({
|
restaurar({
|
||||||
desarrollo: c.desarrollo,
|
desarrollo: c.desarrollo,
|
||||||
unidad: u,
|
unidad: u,
|
||||||
@@ -306,7 +420,7 @@ export default function CotizadorPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||||
<main className="px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
|
<main className="px-4 py-8 pb-28 sm:px-6 lg:px-8 lg:py-10 lg:pb-28">
|
||||||
{/* Encabezado de página */}
|
{/* Encabezado de página */}
|
||||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -599,49 +713,6 @@ export default function CotizadorPage() {
|
|||||||
propuestas={propuestas}
|
propuestas={propuestas}
|
||||||
titulos={propuestas.map((p) => p.titulo)}
|
titulos={propuestas.map((p) => p.titulo)}
|
||||||
/>
|
/>
|
||||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-end">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
|
|
||||||
Propuesta elegida
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="propuesta-elegida"
|
|
||||||
value={Math.min(propuestaIndex, propuestas.length - 1)}
|
|
||||||
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
|
||||||
>
|
|
||||||
{propuestas.map((p, i) => (
|
|
||||||
<option key={p.tipo} value={i}>
|
|
||||||
#{i + 1} {p.titulo}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCompartir}
|
|
||||||
disabled={!puedeGenerarPdf}
|
|
||||||
className="rounded-lg border border-accent bg-white px-5 py-2.5 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
|
||||||
>
|
|
||||||
Compartir
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGenerarPdf}
|
|
||||||
disabled={!puedeGenerarPdf}
|
|
||||||
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{aviso && <p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">{aviso}</p>}
|
|
||||||
{!puedeGenerarPdf && !guardando && (
|
|
||||||
<p className="mt-2 text-right text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Selecciona una unidad para guardar y generar el PDF.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -664,6 +735,71 @@ export default function CotizadorPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */}
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
{propuestaActiva ? (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
|
||||||
|
<span className="truncate">
|
||||||
|
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
|
||||||
|
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
aria-label="Cambiar propuesta elegida"
|
||||||
|
value={Math.min(propuestaIndex, propuestas.length - 1)}
|
||||||
|
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
|
||||||
|
>
|
||||||
|
{propuestas.map((p, i) => (
|
||||||
|
<option key={p.tipo} value={i}>
|
||||||
|
#{i + 1} {p.titulo}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Selecciona una unidad para elegir propuesta, guardar y generar el PDF.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{aviso && (
|
||||||
|
<p role="status" className="w-full text-xs text-[#64748B] dark:text-[#94A3B8] sm:w-auto sm:max-w-xs sm:truncate">
|
||||||
|
{aviso}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCompartir}
|
||||||
|
disabled={!puedeGenerarPdf}
|
||||||
|
className="rounded-lg border border-accent bg-white px-4 py-2 text-sm font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
||||||
|
>
|
||||||
|
Compartir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGuardar}
|
||||||
|
disabled={!puedeGenerarPdf}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:cursor-not-allowed disabled:opacity-40 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]"
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGenerarPdf}
|
||||||
|
disabled={!puedeGenerarPdf}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{guardando ? 'Guardando…' : 'Generar PDF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModalCliente
|
<ModalCliente
|
||||||
abierto={modalClienteAbierto}
|
abierto={modalClienteAbierto}
|
||||||
nombreInicial={watch('nombre')}
|
nombreInicial={watch('nombre')}
|
||||||
|
|||||||
@@ -12,22 +12,82 @@ async function getDB(): Promise<IDBPDatabase> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Todas las operaciones son tolerantes a fallos: sin IndexedDB (navegación
|
||||||
|
// privada, tests) no lanzan, para no romper el flujo que las usa.
|
||||||
export async function guardarUnidades(unidades: unknown[]) {
|
export async function guardarUnidades(unidades: unknown[]) {
|
||||||
const db = await getDB()
|
try {
|
||||||
await db.put('unidades', unidades, 'actual')
|
const db = await getDB()
|
||||||
|
await db.put('unidades', unidades, 'actual')
|
||||||
|
} catch {
|
||||||
|
// Sin almacenamiento: el inventario se recarga del API en la próxima sesión
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cargarUnidadesLocal() {
|
export async function cargarUnidadesLocal(): Promise<unknown> {
|
||||||
const db = await getDB()
|
try {
|
||||||
return db.get('unidades', 'actual')
|
const db = await getDB()
|
||||||
|
return db.get('unidades', 'actual')
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function guardarCotizaciones(cotizaciones: unknown[]) {
|
export async function guardarCotizaciones(cotizaciones: unknown[]) {
|
||||||
const db = await getDB()
|
try {
|
||||||
await db.put('cotizaciones', cotizaciones, 'historial')
|
const db = await getDB()
|
||||||
|
await db.put('cotizaciones', cotizaciones, 'historial')
|
||||||
|
} catch {
|
||||||
|
// Sin almacenamiento
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cargarCotizacionesLocal() {
|
export async function cargarCotizacionesLocal(): Promise<unknown> {
|
||||||
const db = await getDB()
|
try {
|
||||||
return db.get('cotizaciones', 'historial')
|
const db = await getDB()
|
||||||
|
return db.get('cotizaciones', 'historial')
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Cotizaciones guardadas localmente (fallback sin servidor) ----------
|
||||||
|
|
||||||
|
const COTIZACIONES_LS = 'frasa-cotizador:cotizaciones-locales'
|
||||||
|
const MAX_LOCALES = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda una cotización local (id con prefijo 'local-') en IndexedDB.
|
||||||
|
* Si IDB no está disponible (p. ej. navegación privada) cae a localStorage;
|
||||||
|
* si no hay almacenamiento alguno, no lanza: la sesión sigue.
|
||||||
|
*/
|
||||||
|
export async function guardarCotizacionLocal(cotizacion: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
await db.put('cotizaciones', cotizacion, `local:${(cotizacion as { id: string }).id}`)
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// IndexedDB no disponible: intenta localStorage
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const prev = JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
|
||||||
|
localStorage.setItem(COTIZACIONES_LS, JSON.stringify([cotizacion, ...prev].slice(0, MAX_LOCALES)))
|
||||||
|
} catch {
|
||||||
|
// Sin almacenamiento disponible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lee las cotizaciones guardadas localmente (IndexedDB o localStorage) */
|
||||||
|
export async function cargarCotizacionesLocales(): Promise<unknown[]> {
|
||||||
|
try {
|
||||||
|
const db = await getDB()
|
||||||
|
const claves = (await db.getAllKeys('cotizaciones')).filter((k) => String(k).startsWith('local:'))
|
||||||
|
return Promise.all(claves.map((k) => db.get('cotizaciones', k)))
|
||||||
|
} catch {
|
||||||
|
// IndexedDB no disponible: intenta localStorage
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(COTIZACIONES_LS) ?? '[]') as unknown[]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
apps/dashboards/package-lock.json
generated
58
apps/dashboards/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^7.18.3",
|
||||||
"recharts": "^2.15.0"
|
"recharts": "^2.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2128,6 +2129,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssstyle": {
|
"node_modules/cssstyle": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||||
@@ -3271,6 +3285,44 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
|
||||||
|
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
|
||||||
|
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.18.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-smooth": {
|
"node_modules/react-smooth": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
@@ -3424,6 +3476,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^7.18.3",
|
||||||
"recharts": "^2.15.0"
|
"recharts": "^2.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
const CLASE_BASE =
|
||||||
return (
|
'rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md'
|
||||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-shadow duration-200 hover:shadow-md">
|
|
||||||
|
interface Props {
|
||||||
|
titulo: string
|
||||||
|
valor: ReactNode
|
||||||
|
detalle?: string
|
||||||
|
/** Presente = KPI clickeable (drill-down): se renderiza como botón */
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KpiCard({ titulo, valor, detalle, onClick }: Props) {
|
||||||
|
const contenido = (
|
||||||
|
<>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
|
||||||
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
<p className="mt-2 text-3xl font-light text-[#111827] dark:text-[#E2E8F0]">{valor}</p>
|
||||||
{detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>}
|
{detalle && <p className="mt-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]">{detalle}</p>}
|
||||||
</div>
|
</>
|
||||||
|
)
|
||||||
|
if (!onClick) return <div className={CLASE_BASE}>{contenido}</div>
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${CLASE_BASE} w-full cursor-pointer text-left transition-colors hover:border-accent`}
|
||||||
|
>
|
||||||
|
{contenido}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,28 @@ import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } fr
|
|||||||
import { tooltipProps } from '../lib/chart-theme'
|
import { tooltipProps } from '../lib/chart-theme'
|
||||||
import KpiCard from './KpiCard'
|
import KpiCard from './KpiCard'
|
||||||
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
|
import { EstadoVacio, TarjetaGrafica, type AccionesTablero } from './comunes'
|
||||||
import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero'
|
import { funnelMiTablero, type MiTablero } from '../lib/mi-tablero'
|
||||||
import { fetchResumenMiDia } from '../services/mi-dia'
|
import { fetchMiTablero } from '../services/reportes'
|
||||||
|
|
||||||
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros)
|
// Misma paleta del funnel del tablero general (NovaSpark: azules + neutros);
|
||||||
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
|
// rojo para la etapa final "Descartados"
|
||||||
|
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB', '#EF4444']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades,
|
* "Mi tablero": KPIs personales del vendedor logueado y su funnel de conversión
|
||||||
* apartados por vencer) y un funnel simple de sus leads por estado.
|
* (Leads → Oportunidades → Apartados → Vendidos → Descartados).
|
||||||
* Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API).
|
* Fuente: GET /api/reportes/mi-tablero, el mismo endpoint que usa el panel
|
||||||
|
* "Mi día" (DashboardVendedor).
|
||||||
*/
|
*/
|
||||||
export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) {
|
export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (acciones: AccionesTablero) => void }) {
|
||||||
const [resumen, setResumen] = useState<ResumenMiDia | null>(null)
|
const [tablero, setTablero] = useState<MiTablero | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
|
|
||||||
const cargar = useCallback(async () => {
|
const cargar = useCallback(async () => {
|
||||||
setCargando(true)
|
setCargando(true)
|
||||||
try {
|
try {
|
||||||
setResumen(await fetchResumenMiDia())
|
setTablero(await fetchMiTablero())
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
|
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
|
||||||
@@ -40,15 +42,20 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
|
|||||||
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
|
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
|
||||||
}, [alCambiarAcciones, cargar, cargando])
|
}, [alCambiarAcciones, cargar, cargando])
|
||||||
|
|
||||||
const funnel = resumen ? funnelMisLeads(resumen.misLeads) : []
|
const funnel = tablero ? funnelMiTablero(tablero) : []
|
||||||
|
const conversion = tablero?.conversion
|
||||||
|
const tasaConversion =
|
||||||
|
conversion && conversion.leads > 0
|
||||||
|
? `${((conversion.oportunidades / conversion.leads) * 100).toFixed(1)}%`
|
||||||
|
: '—'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="px-4 py-8 sm:px-6 lg:px-8">
|
<main className="px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1>
|
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Mi tablero</h1>
|
||||||
{resumen && (
|
{tablero && (
|
||||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
Actualizado: {new Date(tablero.generadoEn).toLocaleString('es-MX')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -59,39 +66,43 @@ export default function MiTablero({ alCambiarAcciones }: { alCambiarAcciones: (a
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{resumen?.sinMapeoMonday && (
|
{!tablero && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
|
||||||
<p className="mb-8 rounded-lg bg-amber-50 px-4 py-3 text-xs font-medium text-amber-700">
|
{!tablero && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
|
||||||
Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!resumen && !error && cargando && <EstadoVacio mensaje="Cargando…" />}
|
{tablero && conversion && (
|
||||||
{!resumen && !error && !cargando && <EstadoVacio mensaje="Sin datos" />}
|
|
||||||
|
|
||||||
{resumen && (
|
|
||||||
<>
|
<>
|
||||||
<section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<section aria-label="Mis indicadores" className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Mis leads activos"
|
titulo="Leads totales"
|
||||||
valor={resumen.misLeads.length}
|
valor={conversion.leads}
|
||||||
detalle={`${resumen.conteos.leadsNuevosSemana} nuevo(s) esta semana`}
|
detalle={`${conversion.oportunidades} oportunidades · ${conversion.vendidos} vendidos`}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Mis actividades abiertas"
|
titulo="Tasa de conversión"
|
||||||
valor={resumen.conteos.actividadesAbiertas}
|
valor={tasaConversion}
|
||||||
detalle={`${resumen.misActividadesHoy.length} con fecha de hoy o antes`}
|
detalle={`${conversion.oportunidades} de ${conversion.leads} leads`}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Apartados por vencer"
|
titulo="Ventas del mes"
|
||||||
valor={resumen.apartadosPorVencer.length}
|
valor={conversion.vendidos}
|
||||||
detalle="próximos 7 días (pipeline completo)"
|
detalle={`$${(tablero.ventasMes.monto / 1000).toFixed(0)}k MXN · ${tablero.ventasMes.unidades} cotizada(s)`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
titulo="Actividades abiertas"
|
||||||
|
valor={tablero.actividadesPendientes}
|
||||||
|
detalle={`${tablero.actividadesHoy} con fecha de hoy o antes`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
titulo="Leads descartados"
|
||||||
|
valor={conversion.descartados}
|
||||||
|
detalle="etapa terminal DESCARTADO"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Mis leads por estado" className="mt-10">
|
<section aria-label="Funnel de conversión" className="mt-10">
|
||||||
<TarjetaGrafica titulo="Mis leads por estado">
|
<TarjetaGrafica titulo="Funnel de conversión (incluye descartados)">
|
||||||
{funnel.length === 0 ? (
|
{funnel.every((e) => e.valor === 0) ? (
|
||||||
<EstadoVacio mensaje="Sin leads asignados" />
|
<EstadoVacio mensaje="Sin movimiento comercial" />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-72">
|
<div className="h-72">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -43,10 +44,28 @@ const COLORES_STATUS: Record<string, string> = {
|
|||||||
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
|
const COLORES_FUNNEL = ['#94A3B8', '#64748B', '#3B82F6', '#2563EB']
|
||||||
const COLORES_COBRANZA = ['#2563EB', '#F59E0B']
|
const COLORES_COBRANZA = ['#2563EB', '#F59E0B']
|
||||||
|
|
||||||
|
/** Rangos de fecha disponibles para filtrar las gráficas (días hacia atrás) */
|
||||||
|
const RANGOS_DIAS = [7, 30, 90] as const
|
||||||
|
|
||||||
|
/** Rutas de drill-down a otras apps del ecosistema (NGINX las sirve como SPAs aparte) */
|
||||||
|
const RUTA_PLANES_VENCIDOS = '/pay/?vista=todos&status=ACTIVO&sort=atraso&dir=desc'
|
||||||
|
const RUTA_MI_DIA = '/hoy/'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drill-down cruzado de apps: /pay (cobranza) y /hoy (mi-dia) son SPAs
|
||||||
|
* distintas detrás de NGINX; navigate() de react-router no puede salir del
|
||||||
|
* basename de esta app, así que el salto es a nivel document.
|
||||||
|
*/
|
||||||
|
function irAApp(ruta: string): void {
|
||||||
|
window.location.assign(ruta)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario,
|
* Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario,
|
||||||
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
|
* pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/
|
||||||
* admin) agrega la sección Equipo con leads por vendedor.
|
* admin) agrega la sección Equipo con leads por vendedor.
|
||||||
|
* El rango de fechas (?dias=7|30|90) vive en la URL y filtra leads,
|
||||||
|
* oportunidades y perfilamiento; inventario y cobranza son posición actual.
|
||||||
*/
|
*/
|
||||||
export default function TableroGeneral({
|
export default function TableroGeneral({
|
||||||
mostrarEquipo,
|
mostrarEquipo,
|
||||||
@@ -58,23 +77,39 @@ export default function TableroGeneral({
|
|||||||
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
|
const [resumen, setResumen] = useState<ResumenReportes | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
|
// Rango de fechas persistido en la URL: ?dias=7|30|90 (default 30)
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const paramDias = Number(searchParams.get('dias'))
|
||||||
|
const dias = (RANGOS_DIAS as readonly number[]).includes(paramDias) ? paramDias : 30
|
||||||
|
|
||||||
const cargar = useCallback(async () => {
|
const cargar = useCallback(async () => {
|
||||||
setCargando(true)
|
setCargando(true)
|
||||||
try {
|
try {
|
||||||
setResumen(await fetchResumen())
|
setResumen(await fetchResumen(dias))
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
|
setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero')
|
||||||
} finally {
|
} finally {
|
||||||
setCargando(false)
|
setCargando(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [dias])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void cargar()
|
void cargar()
|
||||||
}, [cargar])
|
}, [cargar])
|
||||||
|
|
||||||
|
/** Cambia el rango y lo persiste en la URL (compartible) */
|
||||||
|
const cambiarRango = (nuevo: number) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev)
|
||||||
|
next.set('dias', String(nuevo))
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Expone "Actualizar" al header sticky de DashboardPage
|
// Expone "Actualizar" al header sticky de DashboardPage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
|
alCambiarAcciones({ actualizar: () => void cargar(), cargando })
|
||||||
@@ -89,13 +124,33 @@ export default function TableroGeneral({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="px-4 py-8 sm:px-6 lg:px-8">
|
<main className="px-4 py-8 sm:px-6 lg:px-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||||
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
|
<div>
|
||||||
{resumen && (
|
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Tablero comercial</h1>
|
||||||
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
{resumen && (
|
||||||
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
</p>
|
Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')}
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1" role="group" aria-label="Rango de fechas de las gráficas">
|
||||||
|
<span className="mr-1 text-xs font-medium uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Rango:</span>
|
||||||
|
{RANGOS_DIAS.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => cambiarRango(r)}
|
||||||
|
aria-pressed={dias === r}
|
||||||
|
className={
|
||||||
|
dias === r
|
||||||
|
? 'rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors'
|
||||||
|
: 'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#64748B] transition-colors hover:text-[#111827] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#94A3B8] dark:hover:text-[#E2E8F0]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r} días
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -114,6 +169,7 @@ export default function TableroGeneral({
|
|||||||
titulo="Leads totales"
|
titulo="Leads totales"
|
||||||
valor={resumen.leads.total}
|
valor={resumen.leads.total}
|
||||||
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
|
detalle={`${resumen.leads.nuevosHoy} hoy · ${resumen.leads.nuevosSemana} esta semana`}
|
||||||
|
onClick={() => irAApp(RUTA_MI_DIA)}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Oportunidades activas"
|
titulo="Oportunidades activas"
|
||||||
@@ -132,7 +188,8 @@ export default function TableroGeneral({
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Cartera vencida"
|
titulo="Cartera vencida"
|
||||||
valor={formatoMoneda(resumen.cobranza.montoVencido)}
|
valor={formatoMoneda(resumen.cobranza.montoVencido)}
|
||||||
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s)`}
|
detalle={`${resumen.cobranza.pagosVencidos} pago(s) vencido(s) · click para ver planes`}
|
||||||
|
onClick={() => irAApp(RUTA_PLANES_VENCIDOS)}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
titulo="Planes activos"
|
titulo="Planes activos"
|
||||||
|
|||||||
@@ -79,3 +79,40 @@ export function funnelMisLeads(misLeads: LeadMiDia[]): EtapaFunnelPersonal[] {
|
|||||||
.map(([etapa, valor]) => ({ etapa, valor }))
|
.map(([etapa, valor]) => ({ etapa, valor }))
|
||||||
.sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa))
|
.sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Tablero personal (GET /api/reportes/mi-tablero) ----------
|
||||||
|
// Mismo endpoint que consume DashboardVendedor en la app de Mi día.
|
||||||
|
|
||||||
|
export interface MiTablero {
|
||||||
|
generadoEn: string
|
||||||
|
leadsPorDia: { fecha: string; cantidad: number }[]
|
||||||
|
conversion: {
|
||||||
|
leads: number
|
||||||
|
oportunidades: number
|
||||||
|
apartados: number
|
||||||
|
vendidos: number
|
||||||
|
/** Leads con etapa terminal DESCARTADO; etapa extra al final del funnel */
|
||||||
|
descartados: number
|
||||||
|
}
|
||||||
|
ventasMes: { monto: number; unidades: number }
|
||||||
|
porOrigen: { origen: string; total: number; convertidos: number }[]
|
||||||
|
porDesarrollo: { desarrollo: string; ventas: number }[]
|
||||||
|
actividadesHoy: number
|
||||||
|
actividadesPendientes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etapas del funnel personal desde el endpoint unificado: Leads →
|
||||||
|
* Oportunidades → Apartados → Vendidos, con "Descartados" al final (fuera del
|
||||||
|
* embudo: leads terminados sin conversión) para que no se omitan.
|
||||||
|
*/
|
||||||
|
export function funnelMiTablero(tablero: MiTablero): EtapaFunnelPersonal[] {
|
||||||
|
const c = tablero.conversion
|
||||||
|
return [
|
||||||
|
{ etapa: 'Leads', valor: c.leads },
|
||||||
|
{ etapa: 'Oportunidades', valor: c.oportunidades },
|
||||||
|
{ etapa: 'Apartados', valor: c.apartados },
|
||||||
|
{ etapa: 'Vendidos', valor: c.vendidos },
|
||||||
|
{ etapa: 'Descartados', valor: c.descartados },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { Toaster } from 'react-hot-toast'
|
import { Toaster } from 'react-hot-toast'
|
||||||
import '@fontsource/inter/300.css'
|
import '@fontsource/inter/300.css'
|
||||||
import '@fontsource/inter/400.css'
|
import '@fontsource/inter/400.css'
|
||||||
@@ -11,9 +12,13 @@ import DashboardPage from './pages/DashboardPage'
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthGate>
|
{/* La app vive bajo el prefijo /dash/ de NGINX (vite base); el rango de
|
||||||
<DashboardPage />
|
fechas del tablero general se persiste en la URL (?dias=7|30|90) */}
|
||||||
</AuthGate>
|
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||||
|
<AuthGate>
|
||||||
|
<DashboardPage />
|
||||||
|
</AuthGate>
|
||||||
|
</BrowserRouter>
|
||||||
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
// Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX)
|
// Cliente del endpoint de reportes del API Gateway (misma red, same-origin tras NGINX)
|
||||||
import { conAuth, manejar401 } from './auth'
|
import { conAuth, manejar401 } from './auth'
|
||||||
import type { ResumenReportes } from '../lib/reportes'
|
import type { ResumenReportes } from '../lib/reportes'
|
||||||
|
import type { MiTablero } from '../lib/mi-tablero'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||||
|
|
||||||
/** GET /reportes/resumen; lanza Error con mensaje legible si el API falla */
|
async function obtener<T>(path: string): Promise<T> {
|
||||||
export async function fetchResumen(): Promise<ResumenReportes> {
|
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${API_URL}/reportes/resumen`, conAuth())
|
res = await fetch(`${API_URL}${path}`, conAuth())
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('No se pudo conectar con el servidor')
|
throw new Error('No se pudo conectar con el servidor')
|
||||||
}
|
}
|
||||||
manejar401(res)
|
manejar401(res)
|
||||||
if (!res.ok) throw new Error(`El API respondió ${res.status}`)
|
if (!res.ok) throw new Error(`El API respondió ${res.status}`)
|
||||||
return (await res.json()) as ResumenReportes
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /reportes/resumen; `dias` (7/30/90) acota leads y oportunidades al rango.
|
||||||
|
* Lanza Error con mensaje legible si el API falla.
|
||||||
|
*/
|
||||||
|
export async function fetchResumen(dias?: number): Promise<ResumenReportes> {
|
||||||
|
const query = dias !== undefined && dias > 0 ? `?dias=${Math.floor(dias)}` : ''
|
||||||
|
return obtener<ResumenReportes>(`/reportes/resumen${query}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /reportes/mi-tablero — tablero personal del vendedor (mismo que Mi día) */
|
||||||
|
export async function fetchMiTablero(): Promise<MiTablero> {
|
||||||
|
return obtener<MiTablero>('/reportes/mi-tablero')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { getContactoDetalle, type DetalleContacto } from '../services/api'
|
import { getContactoDetalle, type DatosContacto, type DetalleContacto } from '../services/api'
|
||||||
|
|
||||||
const formatoFecha = (iso: string): string =>
|
const formatoFecha = (iso: string): string =>
|
||||||
iso
|
iso
|
||||||
@@ -47,7 +47,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
|
|||||||
return () => window.removeEventListener('keydown', alTeclear)
|
return () => window.removeEventListener('keydown', alTeclear)
|
||||||
}, [onCerrar])
|
}, [onCerrar])
|
||||||
|
|
||||||
const c = detalle?.contacto ?? {}
|
const c: Partial<DatosContacto> = detalle?.contacto ?? {}
|
||||||
const prop = detalle?.propiedad ?? null
|
const prop = detalle?.propiedad ?? null
|
||||||
const hist = detalle?.historial ?? []
|
const hist = detalle?.historial ?? []
|
||||||
const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] }
|
const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] }
|
||||||
@@ -64,7 +64,7 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
|
|||||||
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4">
|
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p>
|
||||||
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
|
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c.nombre ?? nombre}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -83,15 +83,15 @@ export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }:
|
|||||||
<div className="mt-5 space-y-5">
|
<div className="mt-5 space-y-5">
|
||||||
{/* Datos personales */}
|
{/* Datos personales */}
|
||||||
<Section titulo="Datos personales">
|
<Section titulo="Datos personales">
|
||||||
<Field label="Email" value={c['Email']} />
|
<Field label="Email" value={c.email} />
|
||||||
<Field label="Teléfono" value={c['Teléfono']} />
|
<Field label="Teléfono" value={c.telefono} />
|
||||||
<Field label="Estado" value={c['Estado']} />
|
<Field label="Estado" value={c.estado} />
|
||||||
<Field label="CURP" value={c['CURP']} />
|
<Field label="CURP" value={c.curp} />
|
||||||
<Field label="RFC" value={c['RFC']} />
|
<Field label="RFC" value={c.rfc} />
|
||||||
<Field label="Estado civil" value={c['Estado Civil']} />
|
<Field label="Estado civil" value={c.estadoCivil} />
|
||||||
<Field label="Fecha nacimiento" value={c['Fecha Nacimiento']} />
|
<Field label="Fecha nacimiento" value={c.fechaNacimiento} />
|
||||||
<Field label="Actividad económica" value={c['Actividad Economica']} />
|
<Field label="Actividad económica" value={c.actividadEconomica} />
|
||||||
<Field label="Dirección" value={c['Dirección']} />
|
<Field label="Dirección" value={c.direccion} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Propiedad */}
|
{/* Propiedad */}
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ConfigPage', () => {
|
describe('ConfigPage', () => {
|
||||||
it('renderiza los 4 tabs y muestra "Modo de trabajo" por defecto', () => {
|
it('renderiza los 3 tabs y muestra "Roles y permisos" por defecto', async () => {
|
||||||
render(<ConfigPage onVolver={() => {}} />)
|
render(<ConfigPage onVolver={() => {}} />)
|
||||||
expect(screen.getByText('Configuraciones')).toBeTruthy()
|
expect(screen.getByText('Configuraciones')).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: 'Modo de trabajo' })).toBeTruthy()
|
|
||||||
expect(screen.getByRole('button', { name: 'Roles y permisos' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Roles y permisos' })).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy()
|
||||||
expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: 'Sistema' })).toBeTruthy()
|
||||||
// El tab de "Modo de trabajo" está activo por defecto
|
expect(screen.queryByRole('button', { name: 'Modo de trabajo' })).toBeNull()
|
||||||
expect(screen.getByText('Modo de trabajo')).toBeTruthy()
|
// El tab de "Roles y permisos" está activo por defecto (su contenido carga async)
|
||||||
|
expect(await screen.findByText('Guardar cambios')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('al hacer clic en un tab, muestra su contenido', async () => {
|
it('al hacer clic en un tab, muestra su contenido', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ModoTrabajo } from './ModoTrabajo'
|
|
||||||
import { RolesPermisos } from './RolesPermisos'
|
import { RolesPermisos } from './RolesPermisos'
|
||||||
import { Usuarios } from './Usuarios'
|
import { Usuarios } from './Usuarios'
|
||||||
import { Sistema } from './Sistema'
|
import { Sistema } from './Sistema'
|
||||||
@@ -10,17 +9,16 @@ interface Props {
|
|||||||
onVolver: () => void
|
onVolver: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'modo' | 'roles' | 'usuarios' | 'sistema' | 'auditoria'
|
type Tab = 'roles' | 'usuarios' | 'sistema' | 'auditoria'
|
||||||
|
|
||||||
const TABS_BASE: { key: Tab; label: string }[] = [
|
const TABS_BASE: { key: Tab; label: string }[] = [
|
||||||
{ key: 'modo', label: 'Modo de trabajo' },
|
|
||||||
{ key: 'roles', label: 'Roles y permisos' },
|
{ key: 'roles', label: 'Roles y permisos' },
|
||||||
{ key: 'usuarios', label: 'Usuarios' },
|
{ key: 'usuarios', label: 'Usuarios' },
|
||||||
{ key: 'sistema', label: 'Sistema' },
|
{ key: 'sistema', label: 'Sistema' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ConfigPage({ onVolver }: Props) {
|
export function ConfigPage({ onVolver }: Props) {
|
||||||
const [tab, setTab] = useState<Tab>('modo')
|
const [tab, setTab] = useState<Tab>('roles')
|
||||||
const esAdmin = getSesion()?.usuario.rol === 'admin'
|
const esAdmin = getSesion()?.usuario.rol === 'admin'
|
||||||
const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE
|
const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE
|
||||||
|
|
||||||
@@ -62,7 +60,6 @@ export function ConfigPage({ onVolver }: Props) {
|
|||||||
|
|
||||||
{/* Contenido del tab activo */}
|
{/* Contenido del tab activo */}
|
||||||
<section className="mt-6">
|
<section className="mt-6">
|
||||||
{tab === 'modo' && <ModoTrabajo />}
|
|
||||||
{tab === 'roles' && <RolesPermisos />}
|
{tab === 'roles' && <RolesPermisos />}
|
||||||
{tab === 'usuarios' && <Usuarios />}
|
{tab === 'usuarios' && <Usuarios />}
|
||||||
{tab === 'sistema' && <Sistema />}
|
{tab === 'sistema' && <Sistema />}
|
||||||
|
|||||||
@@ -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', () => {
|
describe('MODULOS', () => {
|
||||||
it('expone los 8 módulos del ecosistema', () => {
|
it('expone los 9 módulos del ecosistema', () => {
|
||||||
expect(MODULOS).toHaveLength(8)
|
expect(MODULOS).toHaveLength(9)
|
||||||
expect(MODULOS.map((m) => m.id)).toEqual([
|
expect(MODULOS.map((m) => m.id)).toEqual([
|
||||||
'cotizador',
|
'cotizador',
|
||||||
'alta-clientes',
|
'alta-clientes',
|
||||||
@@ -19,6 +19,7 @@ describe('MODULOS', () => {
|
|||||||
'mi-dia',
|
'mi-dia',
|
||||||
'contactos',
|
'contactos',
|
||||||
'captura',
|
'captura',
|
||||||
|
'clientes-maestro',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
|
import { listarClientesMaestro, exportarClientesMaestro, type ClienteMaestro } from '../services/clientes-maestro'
|
||||||
import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
|
import { ClientesMaestroTable } from '../components/ClientesMaestroTable'
|
||||||
import ModalClienteMaestro from '../components/ModalClienteMaestro'
|
import ModalClienteMaestro from '../components/ModalClienteMaestro'
|
||||||
import { getSesion } from '../services/auth'
|
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,7 +15,6 @@ const ESTATUSES = ['', 'VENDIDO', 'APARTADO', 'ESCRITURADO', 'PENDIENTE', 'CANCE
|
|||||||
/**
|
/**
|
||||||
* Página / clientes-maestro: tabla paginada con filtros de búsqueda,
|
* Página / clientes-maestro: tabla paginada con filtros de búsqueda,
|
||||||
* desarrollo y estatus. Botón exportar genera xlsx desde los datos filtrados.
|
* desarrollo y estatus. Botón exportar genera xlsx desde los datos filtrados.
|
||||||
* Botón importar (solo admin) sube Excel al backend.
|
|
||||||
*/
|
*/
|
||||||
export function ClientesMaestroPage({ onVolver }: Props) {
|
export function ClientesMaestroPage({ onVolver }: Props) {
|
||||||
const [datos, setDatos] = useState<ClienteMaestro[]>([])
|
const [datos, setDatos] = useState<ClienteMaestro[]>([])
|
||||||
@@ -29,9 +27,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
|
|||||||
const [filtroEstatus, setFiltroEstatus] = useState('')
|
const [filtroEstatus, setFiltroEstatus] = useState('')
|
||||||
const [busquedaDebounced, setBusquedaDebounced] = useState('')
|
const [busquedaDebounced, setBusquedaDebounced] = useState('')
|
||||||
|
|
||||||
const sesion = getSesion()
|
|
||||||
const esAdmin = sesion?.usuario.rol === 'admin'
|
|
||||||
|
|
||||||
// Debounce búsqueda
|
// Debounce búsqueda
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -102,24 +97,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImportar = () => {
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'file'
|
|
||||||
input.accept = '.xlsx,.xls,.csv'
|
|
||||||
input.onchange = async (e) => {
|
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
toast.loading('Importando...', { id: 'importar' })
|
|
||||||
try {
|
|
||||||
// For CSV import we'd need a reader; for now show a message
|
|
||||||
toast.error('La importación se realiza desde el script del backend', { id: 'importar' })
|
|
||||||
} catch {
|
|
||||||
toast.error('Error al importar', { id: 'importar' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
|
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
|
||||||
@@ -141,15 +118,6 @@ export function ClientesMaestroPage({ onVolver }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{esAdmin && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleImportar}
|
|
||||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover"
|
|
||||||
>
|
|
||||||
📤 Importar
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtros */}
|
{/* Filtros */}
|
||||||
|
|||||||
@@ -72,9 +72,24 @@ export interface EventoHistorialContacto {
|
|||||||
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
|
fuente: string // 'Contactos' | 'Leads' | 'Oportunidades' | 'Apartado' | 'App'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DatosContacto {
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
email: string
|
||||||
|
estado: string
|
||||||
|
curp?: string
|
||||||
|
rfc?: string
|
||||||
|
estadoCivil?: string
|
||||||
|
fechaNacimiento?: string
|
||||||
|
actividadEconomica?: string
|
||||||
|
direccion?: string
|
||||||
|
/** Cualquier otro campo que el backend incluya */
|
||||||
|
[campo: string]: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export interface DetalleContacto {
|
export interface DetalleContacto {
|
||||||
/** Columnas del board por título ('nombre' incluido); '' si vacía */
|
/** Campos nombrados del modelo local (antes: títulos de columna de Monday) */
|
||||||
contacto: Record<string, string>
|
contacto: DatosContacto
|
||||||
propiedad: {
|
propiedad: {
|
||||||
id: string
|
id: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ describe('DashboardVendedor', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Ventas del mes')).toBeTruthy()
|
expect(screen.getByText('Ventas del mes')).toBeTruthy()
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Actividades de hoy')).toBeTruthy()
|
expect(screen.getByText('Leads nuevos hoy')).toBeTruthy()
|
||||||
expect(screen.getByText('Leads totales')).toBeTruthy()
|
expect(screen.getByText('Leads totales')).toBeTruthy()
|
||||||
expect(screen.getByText('Funnel conversión')).toBeTruthy()
|
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
|
||||||
expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy()
|
expect(screen.getByText('Leads por día (últimos 30 días)')).toBeTruthy()
|
||||||
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
|
expect(screen.getByText('Funnel de conversión')).toBeTruthy()
|
||||||
expect(screen.getByText('Rendimiento por origen')).toBeTruthy()
|
expect(screen.getByText('Rendimiento por origen')).toBeTruthy()
|
||||||
|
|||||||
@@ -76,16 +76,14 @@ export default function DashboardVendedor() {
|
|||||||
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
|
const mesAnterior = mesActual === 0 ? 11 : mesActual - 1
|
||||||
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
|
const anioAnterior = mesActual === 0 ? anioActual - 1 : anioActual
|
||||||
|
|
||||||
|
// leadsPorDia es el único histórico mensual del tablero (últimos 30 días);
|
||||||
|
// se agrupa por mes parseando 'YYYY-MM-DD' sin Date (evita corrimientos UTC)
|
||||||
let leadsMesActual = 0
|
let leadsMesActual = 0
|
||||||
let leadsMesAnterior = 0
|
let leadsMesAnterior = 0
|
||||||
for (const d of tablero.leadsPorDia) {
|
for (const d of tablero.leadsPorDia) {
|
||||||
const fecha = new Date(d.fecha)
|
const [anio, mes] = d.fecha.split('-').map(Number)
|
||||||
if (fecha.getMonth() === mesActual && fecha.getFullYear() === anioActual) {
|
if (anio === anioActual && mes === mesActual + 1) leadsMesActual += d.cantidad
|
||||||
leadsMesActual += d.cantidad
|
if (anio === anioAnterior && mes === mesAnterior + 1) leadsMesAnterior += d.cantidad
|
||||||
}
|
|
||||||
if (fecha.getMonth() === mesAnterior && fecha.getFullYear() === anioAnterior) {
|
|
||||||
leadsMesAnterior += d.cantidad
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
const nombresMes = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
|
||||||
@@ -100,14 +98,16 @@ export default function DashboardVendedor() {
|
|||||||
{
|
{
|
||||||
metrica: 'Oportunidades',
|
metrica: 'Oportunidades',
|
||||||
mesActual: tablero.conversion.oportunidades,
|
mesActual: tablero.conversion.oportunidades,
|
||||||
mesAnterior: Math.round(tablero.conversion.oportunidades * 0.8), // estimado
|
// Sin histórico mensual: no se estima el mes anterior
|
||||||
|
mesAnterior: null,
|
||||||
nombreMesActual: nombresMes[mesActual],
|
nombreMesActual: nombresMes[mesActual],
|
||||||
nombreMesAnterior: nombresMes[mesAnterior],
|
nombreMesAnterior: nombresMes[mesAnterior],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
metrica: 'Ventas',
|
metrica: 'Ventas',
|
||||||
mesActual: tablero.ventasMes.unidades,
|
mesActual: tablero.ventasMes.unidades,
|
||||||
mesAnterior: Math.round(tablero.ventasMes.unidades * 0.85), // estimado
|
// Sin histórico mensual: no se estima el mes anterior
|
||||||
|
mesAnterior: null,
|
||||||
nombreMesActual: nombresMes[mesActual],
|
nombreMesActual: nombresMes[mesActual],
|
||||||
nombreMesAnterior: nombresMes[mesAnterior],
|
nombreMesAnterior: nombresMes[mesAnterior],
|
||||||
},
|
},
|
||||||
@@ -211,6 +211,9 @@ export default function DashboardVendedor() {
|
|||||||
{/* F1: Comparativa mensual */}
|
{/* F1: Comparativa mensual */}
|
||||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm lg:col-span-2">
|
||||||
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
|
<TituloSeccion>Comparativa mensual (mes actual vs anterior)</TituloSeccion>
|
||||||
|
<p className="-mt-2 mb-3 text-xs text-[#94A3B8]">
|
||||||
|
El mes anterior solo se compara en leads (único histórico disponible); oportunidades y ventas muestran el mes en curso.
|
||||||
|
</p>
|
||||||
<ResponsiveContainer width="100%" height={260}>
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
|
<BarChart data={comparativaMensual} margin={{ top: 8, right: 4, left: -16, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke()} vertical={false} />
|
||||||
|
|||||||
@@ -27,14 +27,23 @@ const CLASE_ACCION_RAPIDA =
|
|||||||
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
|
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
|
||||||
|
|
||||||
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
||||||
{ id: 'info', titulo: 'Información del cliente potencial', icono: User },
|
{ id: 'info', titulo: 'Información', icono: User },
|
||||||
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar },
|
{ id: 'cita', titulo: 'Cita', icono: Calendar },
|
||||||
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
|
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
|
||||||
{ id: 'notas', titulo: 'Notas', icono: FileText },
|
{ id: 'notas', titulo: 'Notas', icono: FileText },
|
||||||
{ id: 'miembros', titulo: 'Objetos de los miembros', icono: Users },
|
{ id: 'miembros', titulo: 'Miembros', icono: Users },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) {
|
export default function DetalleLeadModal({
|
||||||
|
item,
|
||||||
|
onCerrar,
|
||||||
|
onGuardarCita,
|
||||||
|
}: {
|
||||||
|
item: LeadItem
|
||||||
|
onCerrar: () => void
|
||||||
|
/** Guarda la cita de una oportunidad (fechaHora 'YYYY-MM-DDTHH:mm'); la página maneja el error */
|
||||||
|
onGuardarCita?: (fechaHora: string) => Promise<void> | void
|
||||||
|
}) {
|
||||||
const esOp = esOportunidad(item)
|
const esOp = esOportunidad(item)
|
||||||
const esLd = esLead(item)
|
const esLd = esLead(item)
|
||||||
const vendedor = getSesion()?.usuario.nombre ?? ''
|
const vendedor = getSesion()?.usuario.nombre ?? ''
|
||||||
@@ -49,6 +58,23 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
|||||||
const [busquedaNota, setBusquedaNota] = useState('')
|
const [busquedaNota, setBusquedaNota] = useState('')
|
||||||
// --- Tareas ---
|
// --- Tareas ---
|
||||||
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
|
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
|
||||||
|
// --- Cita ---
|
||||||
|
const [fechaCita, setFechaCita] = useState('')
|
||||||
|
const [guardandoCita, setGuardandoCita] = useState(false)
|
||||||
|
|
||||||
|
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
|
||||||
|
async function guardarCita(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!onGuardarCita || fechaCita === '') return
|
||||||
|
setGuardandoCita(true)
|
||||||
|
try {
|
||||||
|
await onGuardarCita(fechaCita)
|
||||||
|
} catch {
|
||||||
|
// el error ya se muestra en la página (toast/aviso del caller)
|
||||||
|
} finally {
|
||||||
|
setGuardandoCita(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cargarNotas = useCallback(async () => {
|
const cargarNotas = useCallback(async () => {
|
||||||
setCargandoNotas(true)
|
setCargandoNotas(true)
|
||||||
@@ -102,8 +128,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-3 bg-white dark:bg-[#1E293B] z-10">
|
<div className="absolute top-0 left-0 right-0 flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-3 bg-white dark:bg-[#1E293B] z-10">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Editar "{item.nombre}"</h2>
|
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Bitácora de {item.nombre}</h2>
|
||||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Añadir y editar oportunidad Detalles, Actividades, notas y Cita.</p>
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">Seguimiento del cliente: información, citas, tareas y notas</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onCerrar} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#334155]"><X className="h-5 w-5" /></button>
|
<button type="button" onClick={onCerrar} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#334155]"><X className="h-5 w-5" /></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,14 +194,37 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
|||||||
|
|
||||||
{tab === 'cita' && (
|
{tab === 'cita' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Reserve o actualice una cita</h3>
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Cita</h3>
|
||||||
{esOp && item.fechaCita ? (
|
{esOp && item.fechaCita ? (
|
||||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
||||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">Cita programada</p>
|
||||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8] mt-1">{item.fechaCita.replace('T', ' a las ')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : esOp ? (
|
||||||
|
<form onSubmit={guardarCita} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="cita-fecha" className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
|
||||||
|
Fecha y hora de la cita
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cita-fecha"
|
||||||
|
type="datetime-local"
|
||||||
|
value={fechaCita}
|
||||||
|
onChange={(e) => setFechaCita(e.target.value)}
|
||||||
|
disabled={guardandoCita}
|
||||||
|
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={guardandoCita || fechaCita === ''}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{guardandoCita ? 'Guardando…' : 'Guardar cita'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-[#94A3B8]">No hay cita programada. Use los botones de fase en la lista para agendar una.</p>
|
<p className="text-sm text-[#94A3B8]">No hay cita programada. Las citas se agendan desde Mis oportunidades.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -267,7 +316,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
|
|||||||
|
|
||||||
{tab === 'miembros' && (
|
{tab === 'miembros' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Objetos de los miembros</h3>
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Miembros</h3>
|
||||||
<p className="text-sm text-[#94A3B8]">Información de los miembros del equipo asignados a este lead.</p>
|
<p className="text-sm text-[#94A3B8]">Información de los miembros del equipo asignados a este lead.</p>
|
||||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] p-4">
|
||||||
<p className="text-xs text-[#94A3B8]">Vendedor: {(item as any).vendedor ?? '—'}</p>
|
<p className="text-xs text-[#94A3B8]">Vendedor: {(item as any).vendedor ?? '—'}</p>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import ScoreBadge from './ScoreBadge'
|
|
||||||
|
|
||||||
/** Columna genérica del kanban */
|
/** Columna genérica del kanban */
|
||||||
interface ColumnaDef {
|
interface ColumnaDef {
|
||||||
@@ -77,7 +76,6 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
|
||||||
{'score' in item && <ScoreBadge score={Number((item as Record<string, unknown>).score) || 0} />}
|
|
||||||
</div>
|
</div>
|
||||||
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
|
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
|
||||||
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}
|
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}
|
||||||
|
|||||||
@@ -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,50 +112,57 @@ export interface ResumenMiDia {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
|
/**
|
||||||
// vocabulario FRASA verificado y sembrado en Monday el 2026-09-04
|
* VOCABULARIO canónico FRASA: única fuente de verdad de fases, estados,
|
||||||
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const
|
* etapas, motivos, subcategorías, orígenes y desarrollos. Las constantes
|
||||||
|
* individuales (FASES_OPORTUNIDAD, MOTIVOS_NO_COMPRA…) se derivan de aquí.
|
||||||
|
* Espejo del backend en api-gateway/src/config/vocabulario.ts (mantener
|
||||||
|
* sincronizados).
|
||||||
|
*/
|
||||||
|
export const VOCABULARIO = {
|
||||||
|
// Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del
|
||||||
|
// API + NUEVO/DESCARTADO)
|
||||||
|
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
|
||||||
|
// Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
|
||||||
|
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
|
||||||
|
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
||||||
|
FASES_APARTADO: ['Pago Apartado', 'Alta Enkontrol', 'Entrego Documentacion', 'Cita Clientes', 'Contrato Enkontrol', 'Firma Cliente', 'Cancelacion Apartado'],
|
||||||
|
// Fases de escrituración (pipeline posterior a 'Firma Cliente')
|
||||||
|
FASES_ESCRITURACION: ['Venta', 'Seguimiento', 'Integracion', 'X Autorizar', 'Autorizado', 'X Escriturar', 'Escriturado', 'Titulado', 'Cancelo'],
|
||||||
|
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA)
|
||||||
|
MOTIVOS_NO_COMPRA: ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'],
|
||||||
|
// Subcategorías por categoría (catálogo del tablero Motivos No Compra,
|
||||||
|
// sembrado en Frasa-CAS el 2026-09-04; 'Mesualidad' y 'Cambo de Planes'
|
||||||
|
// conservan las faltas de ortografía originales del tablero del cliente)
|
||||||
|
SUBCATEGORIAS: {
|
||||||
|
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
|
||||||
|
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
|
||||||
|
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
|
||||||
|
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
|
||||||
|
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
|
||||||
|
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
|
||||||
|
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
|
||||||
|
},
|
||||||
|
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead'
|
||||||
|
// (Oportunidades), verificados y sembrados en Monday el 2026-09-04
|
||||||
|
ORIGENES: ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'],
|
||||||
|
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
||||||
|
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'],
|
||||||
|
} as const
|
||||||
|
|
||||||
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
|
// Constantes derivadas (nombres estables que consume el resto de la app)
|
||||||
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
|
export const ORIGENES_LEAD = VOCABULARIO.ORIGENES
|
||||||
|
|
||||||
// Estados de la columna status del board Leads (vocabulario real del API:
|
export const DESARROLLOS_LEAD = VOCABULARIO.DESARROLLOS
|
||||||
// ESTADOS_LEAD_PERMITIDOS + NUEVO/DESCARTADO); alimenta el filtro por estado
|
|
||||||
export const ESTADOS_LEAD = [
|
|
||||||
'NUEVO',
|
|
||||||
'CONTACTADO',
|
|
||||||
'Intento de contacto',
|
|
||||||
'INTERESADO',
|
|
||||||
'AGENDA CITA',
|
|
||||||
'CALIFICADO',
|
|
||||||
'DESCARTADO',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
|
// Alimenta el filtro por estado de Mis leads
|
||||||
export const MOTIVOS_NO_COMPRA = [
|
export const ESTADOS_LEAD = VOCABULARIO.FASES_LEAD
|
||||||
'Precio',
|
|
||||||
'Producto',
|
export const MOTIVOS_NO_COMPRA = VOCABULARIO.MOTIVOS_NO_COMPRA
|
||||||
'Ubicacion',
|
|
||||||
'Financiamiento',
|
|
||||||
'Competencia',
|
|
||||||
'Cliente',
|
|
||||||
'Timing',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
|
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
|
||||||
|
|
||||||
// Subcategorías por categoría del tablero Motivos No Compra (catálogo del
|
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, readonly string[]> = VOCABULARIO.SUBCATEGORIAS
|
||||||
// workspace de referencia CRM FRASA, sembrado en Frasa-CAS el 2026-09-04).
|
|
||||||
// Espejo de SUBCATEGORIAS_NO_COMPRA del API (mantener sincronizados).
|
|
||||||
export const SUBCATEGORIAS_NO_COMPRA: Record<MotivoNoCompra, string[]> = {
|
|
||||||
Precio: ['Precio Total', 'Enganche', 'Mesualidad'],
|
|
||||||
Producto: ['Distribucion', 'Tamaño', 'Recamaras', 'Baños', 'Amenidades', 'Acabados', 'Estacionamiento', 'Seguridad', 'Inventario'],
|
|
||||||
Ubicacion: ['Zona', 'Accesos', 'Distancia', 'Servicios', 'Seguridad', 'Escuela', 'Trabajo'],
|
|
||||||
Financiamiento: ['No Califica', 'Credito Insuficiente', 'Tasa de interes', 'Mensualidad Alta', 'Enganche Alto', 'Forma de Pago', 'Infonavit Insuficiente', 'No tiene liquidez'],
|
|
||||||
Competencia: ['Precio', 'Producto', 'Ubicacion', 'Promocion', 'Financiamiento'],
|
|
||||||
Cliente: ['Cambo de Planes', 'Decision Familiar', 'No estaba Listo', 'Emergencia Personal'],
|
|
||||||
Timing: ['Compra Posterior', 'Esperando Credito', 'Esperando Venta de Propiedad', 'Esperando Proyecto'],
|
|
||||||
}
|
|
||||||
|
|
||||||
// LADAs soportadas en la captura de teléfonos (México default, USA opcional)
|
// LADAs soportadas en la captura de teléfonos (México default, USA opcional)
|
||||||
export const LADAS = [
|
export const LADAS = [
|
||||||
@@ -193,20 +200,11 @@ export function urlWhatsApp(nombre: string, telefono: string, vendedor: string):
|
|||||||
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
|
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
|
// Fases del funnel de Oportunidades y del pipeline Enkontrol (del VOCABULARIO)
|
||||||
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
|
export const FASES_OPORTUNIDAD = VOCABULARIO.FASES_OPORTUNIDAD
|
||||||
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
|
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
|
||||||
|
|
||||||
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
|
export const FASES_APARTADO = VOCABULARIO.FASES_APARTADO
|
||||||
export const FASES_APARTADO = [
|
|
||||||
'Pago Apartado',
|
|
||||||
'Alta Enkontrol',
|
|
||||||
'Entrego Documentacion',
|
|
||||||
'Cita Clientes',
|
|
||||||
'Contrato Enkontrol',
|
|
||||||
'Firma Cliente',
|
|
||||||
'Cancelacion Apartado',
|
|
||||||
] as const
|
|
||||||
export type FaseApartado = (typeof FASES_APARTADO)[number]
|
export type FaseApartado = (typeof FASES_APARTADO)[number]
|
||||||
|
|
||||||
// Texto bonito de los botones (el valor que se manda al API es el label real)
|
// Texto bonito de los botones (el valor que se manda al API es el label real)
|
||||||
@@ -220,17 +218,7 @@ export const ETIQUETA_FASE_APARTADO: Record<FaseApartado, string> = {
|
|||||||
'Cancelacion Apartado': 'Cancelación',
|
'Cancelacion Apartado': 'Cancelación',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FASES_ESCRITURACION = [
|
export const FASES_ESCRITURACION = VOCABULARIO.FASES_ESCRITURACION
|
||||||
'Venta',
|
|
||||||
'Seguimiento',
|
|
||||||
'Integracion',
|
|
||||||
'X Autorizar',
|
|
||||||
'Autorizado',
|
|
||||||
'X Escriturar',
|
|
||||||
'Escriturado',
|
|
||||||
'Titulado',
|
|
||||||
'Cancelo',
|
|
||||||
] as const
|
|
||||||
export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number]
|
export type FaseEscrituracion = (typeof FASES_ESCRITURACION)[number]
|
||||||
|
|
||||||
export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = {
|
export const ETIQUETA_FASE_ESCRITURACION: Record<FaseEscrituracion, string> = {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import { exportarLeads, exportarOportunidades, exportarApartados } from '../serv
|
|||||||
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
|
||||||
import { getSesion } from '../services/auth'
|
import { getSesion } from '../services/auth'
|
||||||
import DashboardVendedor from '../components/DashboardVendedor'
|
import DashboardVendedor from '../components/DashboardVendedor'
|
||||||
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
|
|
||||||
import KanbanView from '../components/KanbanView'
|
import KanbanView from '../components/KanbanView'
|
||||||
import CalendarioCitas from '../components/CalendarioCitas'
|
import CalendarioCitas from '../components/CalendarioCitas'
|
||||||
import DetalleLeadModal from '../components/DetalleLeadModal'
|
import DetalleLeadModal from '../components/DetalleLeadModal'
|
||||||
@@ -618,7 +617,6 @@ function FilaLead({
|
|||||||
>
|
>
|
||||||
{lead.nombre}
|
{lead.nombre}
|
||||||
</button>
|
</button>
|
||||||
<ScoreBadge score={calcularScore(lead)} />
|
|
||||||
|
|
||||||
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
|
||||||
<span
|
<span
|
||||||
@@ -1677,6 +1675,17 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
<DetalleLeadModal
|
<DetalleLeadModal
|
||||||
item={leadSeleccionado}
|
item={leadSeleccionado}
|
||||||
onCerrar={() => setLeadSeleccionado(null)}
|
onCerrar={() => setLeadSeleccionado(null)}
|
||||||
|
onGuardarCita={
|
||||||
|
'modeloInteres' in leadSeleccionado
|
||||||
|
? (fechaHora) => {
|
||||||
|
const op = leadSeleccionado
|
||||||
|
return ejecutar(
|
||||||
|
() => cambiarEtapaOportunidad(op.id, 'Agendo Cita', fechaHora),
|
||||||
|
'No se pudo guardar la cita',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user