feat(mi-dia): gate de afluencia para apartar, pestana Afluencia en bitacora, cita cliente con fecha en calendario, filtros por desarrollo/origen corregidos, rangos salariales nuevos y soporte como acceso directo
This commit is contained in:
@@ -60,8 +60,27 @@ export const VOCABULARIO = {
|
|||||||
// Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO
|
// Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO
|
||||||
// agregados 2026-09 — existen en captura e inventario)
|
// agregados 2026-09 — existen en captura e inventario)
|
||||||
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'],
|
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'],
|
||||||
|
// Campos de calificación que hacen "completa" una afluencia: sin ellos la
|
||||||
|
// oportunidad NO puede avanzar a 'Aparto' (regla de negocio del cliente)
|
||||||
|
CAMPOS_AFLUENCIA_REQUERIDOS: ['estadoCivil', 'tipoEmpleo', 'rangoSalarial', 'tipoEconomia', 'califica', 'loQuierePara'],
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/** Campos de afluencia faltantes en un datosJson ('[]' = afluencia completa) */
|
||||||
|
export function camposAfluenciaFaltantes(datosJson: string | null): string[] {
|
||||||
|
let datos: Record<string, unknown> = {}
|
||||||
|
if (datosJson) {
|
||||||
|
try { datos = JSON.parse(datosJson) as Record<string, unknown> } catch { datos = {} }
|
||||||
|
}
|
||||||
|
return VOCABULARIO.CAMPOS_AFLUENCIA_REQUERIDOS.filter(
|
||||||
|
(c) => typeof datos[c] !== 'string' || (datos[c] as string).trim() === '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ¿La fila tiene la afluencia completa? (gate de 'Aparto' y flag de Mi día) */
|
||||||
|
export function afluenciaCompleta(datosJson: string | null): boolean {
|
||||||
|
return camposAfluenciaFaltantes(datosJson).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
|
export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
|
||||||
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
|
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
|
||||||
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number]
|
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number]
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const cambiarEstadoSchema = z.object({
|
|||||||
motivo: z.string().optional(),
|
motivo: z.string().optional(),
|
||||||
subcategoria: z.string().optional(),
|
subcategoria: z.string().optional(),
|
||||||
comentario: z.string().optional(),
|
comentario: z.string().optional(),
|
||||||
|
// En 'Cita Clientes' puede setear la fecha y hora de la cita
|
||||||
|
fecha: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,9 +35,32 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
|||||||
const body = cambiarEstadoSchema.parse(req.body)
|
const body = cambiarEstadoSchema.parse(req.body)
|
||||||
const itemId = String(req.params['itemId'])
|
const itemId = String(req.params['itemId'])
|
||||||
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
|
||||||
|
fecha: body.fecha,
|
||||||
usuarioId: req.usuarioToken?.sub,
|
usuarioId: req.usuarioToken?.sub,
|
||||||
sombra: !esIdLocal(itemId),
|
sombra: !esIdLocal(itemId),
|
||||||
})
|
})
|
||||||
|
// 'Cita Clientes' con fecha: agenda la cita en el calendario del vendedor del
|
||||||
|
// apartado (ActividadLocal ligada a la captura; reaperturas reagendan: se
|
||||||
|
// cierran las citas abiertas previas de ese apartado y se crea la nueva).
|
||||||
|
if (body.estado === 'Cita Clientes' && body.fecha !== undefined && body.fecha !== '') {
|
||||||
|
const fila = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||||
|
const nombreCliente = fila?.nombreLead ?? 'Cliente'
|
||||||
|
const usuarioDestino = fila?.usuarioId ?? req.usuarioToken?.sub ?? 'desconocido'
|
||||||
|
await prisma.actividadLocal.updateMany({
|
||||||
|
where: { leadCapturaId: itemId, tipo: 'CITA', estado: 'ABIERTA' },
|
||||||
|
data: { estado: 'CERRADA', fechaCierre: new Date() },
|
||||||
|
})
|
||||||
|
await prisma.actividadLocal.create({
|
||||||
|
data: {
|
||||||
|
titulo: `Cita cliente: ${nombreCliente}`,
|
||||||
|
descripcion: `Cita de cliente del apartado (${itemId})`,
|
||||||
|
usuarioId: usuarioDestino,
|
||||||
|
leadCapturaId: itemId,
|
||||||
|
fechaVencimiento: new Date(body.fecha),
|
||||||
|
tipo: 'CITA',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
|
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
|
||||||
// para los reportes de cancelación (Cliente/Motivo).
|
// para los reportes de cancelación (Cliente/Motivo).
|
||||||
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
|
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'
|
||||||
|
|||||||
@@ -124,7 +124,15 @@ const oportunidadLocal = {
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modelo: 'SOHO',
|
modelo: 'SOHO',
|
||||||
datosJson: null,
|
// Afluencia completa: el gate de negocio exige el perfil para apartar
|
||||||
|
datosJson: JSON.stringify({
|
||||||
|
estadoCivil: 'Casado(a)',
|
||||||
|
tipoEmpleo: 'Asalariado',
|
||||||
|
rangoSalarial: '20,000-40,000',
|
||||||
|
tipoEconomia: 'Economía mexicana',
|
||||||
|
califica: 'Sí',
|
||||||
|
loQuierePara: 'Para vivir',
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('crearCliente (POST /api/clientes) — alta local', () => {
|
describe('crearCliente (POST /api/clientes) — alta local', () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { VOCABULARIO } from '../config/vocabulario.js'
|
import { VOCABULARIO, afluenciaCompleta } from '../config/vocabulario.js'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||||
import * as auditoria from '../services/auditoria.service.js'
|
import * as auditoria from '../services/auditoria.service.js'
|
||||||
@@ -226,6 +226,26 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
|
|||||||
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id)
|
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/afluencias/:leadId — perfil de afluencia capturado en el datosJson
|
||||||
|
* de la fila (lead/oportunidad/apartado). Solo valores de texto no vacíos.
|
||||||
|
* Alimenta la pestaña "Afluencia" de la bitácora del cliente en Mi día.
|
||||||
|
*/
|
||||||
|
export async function obtenerAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const leadId = String(req.params['leadId'])
|
||||||
|
const fila = await prisma.leadCaptura.findUnique({ where: { leadId } })
|
||||||
|
if (!fila) throw new NotFoundError('No existe el registro solicitado')
|
||||||
|
let datos: Record<string, unknown> = {}
|
||||||
|
if (fila.datosJson) {
|
||||||
|
try { datos = JSON.parse(fila.datosJson) as Record<string, unknown> } catch { datos = {} }
|
||||||
|
}
|
||||||
|
const perfil: Record<string, string> = {}
|
||||||
|
for (const [clave, valor] of Object.entries(datos)) {
|
||||||
|
if (typeof valor === 'string' && valor.trim() !== '') perfil[clave] = valor
|
||||||
|
}
|
||||||
|
res.json({ leadId, nombre: fila.nombreLead, perfil, afluenciaCompleta: afluenciaCompleta(fila.datosJson) })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cambio de estado en modo local: guarda la etapa (label del board) en la fila
|
* Cambio de estado en modo local: guarda la etapa (label del board) en la fila
|
||||||
* local. Con id heredado sin fila local (items viejos del CRM anterior)
|
* local. Con id heredado sin fila local (items viejos del CRM anterior)
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => {
|
it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => {
|
||||||
|
// La oportunidad YA tiene afluencia completa (gate de negocio: sin
|
||||||
|
// afluencia no se puede apartar)
|
||||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||||
leadId: 'loc_op',
|
leadId: 'loc_op',
|
||||||
usuarioId: 'u-1',
|
usuarioId: 'u-1',
|
||||||
@@ -163,7 +165,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: 'ATELIER',
|
desarrollo: 'ATELIER',
|
||||||
modelo: 'SOHO',
|
modelo: 'SOHO',
|
||||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
datosJson: JSON.stringify({
|
||||||
|
estadoCivil: 'Casado',
|
||||||
|
tipoEmpleo: 'Formal',
|
||||||
|
rangoSalarial: 'B. 20,000-40,000',
|
||||||
|
tipoEconomia: 'Formal',
|
||||||
|
califica: 'Si',
|
||||||
|
loQuierePara: 'Para vivir',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const { res, json } = resFake()
|
const { res, json } = resFake()
|
||||||
|
|
||||||
@@ -179,7 +189,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
board: 'APARTADO',
|
board: 'APARTADO',
|
||||||
etapa: 'Pago Apartado',
|
etapa: 'Pago Apartado',
|
||||||
nombreLead: 'Luis Demo',
|
nombreLead: 'Luis Demo',
|
||||||
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
datosJson: JSON.stringify({
|
||||||
|
estadoCivil: 'Casado',
|
||||||
|
tipoEmpleo: 'Formal',
|
||||||
|
rangoSalarial: 'B. 20,000-40,000',
|
||||||
|
tipoEconomia: 'Formal',
|
||||||
|
califica: 'Si',
|
||||||
|
loQuierePara: 'Para vivir',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
// La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
|
// La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
|
||||||
@@ -190,6 +208,26 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
|
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("'Aparto' SIN afluencia completa: se rechaza con 400 antes de crear nada", async () => {
|
||||||
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||||
|
leadId: 'loc_op',
|
||||||
|
usuarioId: 'u-1',
|
||||||
|
nombreLead: 'Luis Demo',
|
||||||
|
telefono: '6641112233',
|
||||||
|
email: null,
|
||||||
|
desarrollo: 'ATELIER',
|
||||||
|
modelo: 'SOHO',
|
||||||
|
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
|
||||||
|
})
|
||||||
|
const { res } = resFake()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res),
|
||||||
|
).rejects.toThrow(/afluencia/i)
|
||||||
|
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||||
|
expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
|
it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
|
||||||
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||||
leadId: 'loc_op',
|
leadId: 'loc_op',
|
||||||
@@ -199,7 +237,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modelo: null,
|
modelo: null,
|
||||||
datosJson: null,
|
datosJson: JSON.stringify({
|
||||||
|
estadoCivil: 'Casado',
|
||||||
|
tipoEmpleo: 'Formal',
|
||||||
|
rangoSalarial: 'B. 20,000-40,000',
|
||||||
|
tipoEconomia: 'Formal',
|
||||||
|
califica: 'Si',
|
||||||
|
loQuierePara: 'Para vivir',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
|
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
|
||||||
const { res } = resFake()
|
const { res } = resFake()
|
||||||
@@ -212,7 +257,17 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
select: { unidadCodigo: true },
|
select: { unidadCodigo: true },
|
||||||
})
|
})
|
||||||
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ datosJson: JSON.stringify({ unidadCodigo: 'SENDA-411-1-7' }) }),
|
data: expect.objectContaining({
|
||||||
|
datosJson: JSON.stringify({
|
||||||
|
estadoCivil: 'Casado',
|
||||||
|
tipoEmpleo: 'Formal',
|
||||||
|
rangoSalarial: 'B. 20,000-40,000',
|
||||||
|
tipoEconomia: 'Formal',
|
||||||
|
califica: 'Si',
|
||||||
|
loQuierePara: 'Para vivir',
|
||||||
|
unidadCodigo: 'SENDA-411-1-7',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
|
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
|
||||||
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
|
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
|
||||||
@@ -229,7 +284,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
|
|||||||
email: null,
|
email: null,
|
||||||
desarrollo: null,
|
desarrollo: null,
|
||||||
modelo: null,
|
modelo: null,
|
||||||
datosJson: null,
|
datosJson: JSON.stringify({
|
||||||
|
estadoCivil: 'Casado',
|
||||||
|
tipoEmpleo: 'Formal',
|
||||||
|
rangoSalarial: 'B. 20,000-40,000',
|
||||||
|
tipoEconomia: 'Formal',
|
||||||
|
califica: 'Si',
|
||||||
|
loQuierePara: 'Para vivir',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const { res } = resFake()
|
const { res } = resFake()
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda
|
|||||||
import {
|
import {
|
||||||
cambiarEstadoLeadEndpoint,
|
cambiarEstadoLeadEndpoint,
|
||||||
crearAfluenciaEndpoint,
|
crearAfluenciaEndpoint,
|
||||||
|
obtenerAfluenciaEndpoint,
|
||||||
crearLeadEndpoint,
|
crearLeadEndpoint,
|
||||||
buscarLeadsEndpoint,
|
buscarLeadsEndpoint,
|
||||||
convertirLeadEndpoint,
|
convertirLeadEndpoint,
|
||||||
@@ -305,6 +306,8 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
|
|||||||
|
|
||||||
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
|
||||||
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
|
||||||
|
// Perfil de afluencia capturado de un lead/oportunidad/apartado (bitácora de Mi día)
|
||||||
|
apiRouter.get('/afluencias/:leadId', obtenerAfluenciaEndpoint)
|
||||||
|
|
||||||
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
|
||||||
|
|
||||||
|
|||||||
@@ -234,9 +234,26 @@ describe('CrmLocalService.apartar', () => {
|
|||||||
email: 'luis@correo.com',
|
email: 'luis@correo.com',
|
||||||
desarrollo: 'ATELIER',
|
desarrollo: 'ATELIER',
|
||||||
modelo: 'SOHO',
|
modelo: 'SOHO',
|
||||||
datosJson: '{"edad":35}',
|
datosJson: JSON.stringify({
|
||||||
|
edad: 35,
|
||||||
|
estadoCivil: 'Casado',
|
||||||
|
tipoEmpleo: 'Formal',
|
||||||
|
rangoSalarial: 'B. 20,000-40,000',
|
||||||
|
tipoEconomia: 'Formal',
|
||||||
|
califica: 'Si',
|
||||||
|
loQuierePara: 'Para vivir',
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it('sin afluencia completa rechaza el apartado (regla de negocio)', async () => {
|
||||||
|
prismaMock.leadCaptura.findUnique.mockResolvedValue({
|
||||||
|
...filaOportunidad,
|
||||||
|
datosJson: '{"edad":35}',
|
||||||
|
})
|
||||||
|
await expect(svc.apartar('loc_op', 'u-1')).rejects.toThrow(/afluencia/i)
|
||||||
|
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
|
it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
|
||||||
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
|
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
|
||||||
const apartado = await svc.apartar('loc_op', 'u-1')
|
const apartado = await svc.apartar('loc_op', 'u-1')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
import { afluenciaCompleta } from '../config/vocabulario.js'
|
||||||
import { claveApartadoCreado } from './automatizaciones.service.js'
|
import { claveApartadoCreado } from './automatizaciones.service.js'
|
||||||
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
||||||
import { prisma } from './prisma.js'
|
import { prisma } from './prisma.js'
|
||||||
@@ -229,9 +230,10 @@ export class CrmLocalService {
|
|||||||
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
|
||||||
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
|
// Guardar fechaCita en datosJson (oportunidad y apartado) para que la
|
||||||
|
// muestre Mi día y el calendario
|
||||||
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
|
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
|
||||||
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
|
if (opciones.fecha !== undefined && (board === 'OPORTUNIDADES' || board === 'APARTADO')) {
|
||||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
|
||||||
let datos: Record<string, unknown> = {}
|
let datos: Record<string, unknown> = {}
|
||||||
if (existente?.datosJson) {
|
if (existente?.datosJson) {
|
||||||
@@ -347,6 +349,11 @@ export class CrmLocalService {
|
|||||||
!esIdLocal(oportunidadIdLocal),
|
!esIdLocal(oportunidadIdLocal),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regla de negocio: no se aparta sin la afluencia completa del cliente
|
||||||
|
if (!afluenciaCompleta(oportunidad.datosJson)) {
|
||||||
|
throw new AppError(400, 'Primero debes capturar la afluencia del cliente (botón Afluencia) antes de apartar')
|
||||||
|
}
|
||||||
|
|
||||||
const clave = claveApartadoCreado(oportunidadIdLocal)
|
const clave = claveApartadoCreado(oportunidadIdLocal)
|
||||||
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
|
||||||
if (previo !== null) {
|
if (previo !== null) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* resuelve por el usuarioId de cada fila.
|
* resuelve por el usuarioId de cada fila.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { camposAfluenciaFaltantes } from '../config/vocabulario.js'
|
||||||
|
|
||||||
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
|
||||||
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
||||||
|
|
||||||
@@ -133,6 +135,8 @@ export interface OportunidadMiDia {
|
|||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
||||||
fechaCita?: string | undefined
|
fechaCita?: string | undefined
|
||||||
|
/** ¿Afluencia con perfil completo? Sin esto no puede avanzar a 'Aparto' */
|
||||||
|
afluenciaCompleta: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
|
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
|
||||||
@@ -339,6 +343,7 @@ export function resumenMiDiaLocal(
|
|||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
fechaCita,
|
fechaCita,
|
||||||
|
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe('AltaAfluenciaForm', () => {
|
|||||||
fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } })
|
fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
|
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
|
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: 'F-25001.00-30000.00' } })
|
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '20,000-40,000' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } })
|
fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } })
|
||||||
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
|
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
|
||||||
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
|
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
|
||||||
@@ -141,7 +141,7 @@ describe('AltaAfluenciaForm', () => {
|
|||||||
estadoCivil: 'Casado(a)',
|
estadoCivil: 'Casado(a)',
|
||||||
tipoEmpleo: 'Asalariado',
|
tipoEmpleo: 'Asalariado',
|
||||||
ocupacion: 'Ingeniero',
|
ocupacion: 'Ingeniero',
|
||||||
rangoSalarial: 'F-25001.00-30000.00',
|
rangoSalarial: '20,000-40,000',
|
||||||
credito: 'Infonavit',
|
credito: 'Infonavit',
|
||||||
tipoEconomia: 'Economía mexicana',
|
tipoEconomia: 'Economía mexicana',
|
||||||
califica: 'Sí',
|
califica: 'Sí',
|
||||||
|
|||||||
@@ -582,19 +582,14 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
|
|||||||
className={inputClase}
|
className={inputClase}
|
||||||
>
|
>
|
||||||
<option value="">— Selecciona —</option>
|
<option value="">— Selecciona —</option>
|
||||||
<option value="A-0-10000.00">A-0-10000.00</option>
|
<option value="0-20,000">A. 0 - 20,000</option>
|
||||||
<option value="B-10000.01-15000.00">B-10000.01-15000.00</option>
|
<option value="20,000-40,000">B. 20,000 - 40,000</option>
|
||||||
<option value="C-15000.01-20000.00">C-15000.01-20000.00</option>
|
<option value="40,001-50,000">C. 40,001 - 50,000</option>
|
||||||
<option value="D-17000.01-22000.00">D-17000.01-22000.00</option>
|
<option value="60,001-80,000">D. 60,001 - 80,000</option>
|
||||||
<option value="E-20001.00-25000.00">E-20001.00-25000.00</option>
|
<option value="80,001-100,000">E. 80,001 - 100,000</option>
|
||||||
<option value="F-25001.00-30000.00">F-25001.00-30000.00</option>
|
<option value="120,001-140,000">F. 120,001 - 140,000</option>
|
||||||
<option value="G-30001.00-35000.00">G-30001.00-35000.00</option>
|
<option value="140,001-180,000">G. 140,001 - 180,000</option>
|
||||||
<option value="H-35001.00-40000.00">H-35001.00-40000.00</option>
|
<option value="200,000-500,000">H. 200,000 - 500,000</option>
|
||||||
<option value="I-40001.00-45000.00">I-40001.00-45000.00</option>
|
|
||||||
<option value="J-45001.00-50000.00">J-45001.00-50000.00</option>
|
|
||||||
<option value="K-50001.00-75000.00">K-50001.00-75000.00</option>
|
|
||||||
<option value="L-75001.00-100000.00">L-75001.00-100000.00</option>
|
|
||||||
<option value="M-100001.00-500000.00">M-100001.00-500000.00</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -1,197 +1,18 @@
|
|||||||
import { useCallback, useEffect, useState, type FormEvent } from 'react'
|
/**
|
||||||
import {
|
* Módulo de Soporte: acceso directo al formulario oficial de solicitudes
|
||||||
DEPARTAMENTOS_SOPORTE,
|
* internas (Monday WorkForms). Ya no se registra nada en este sistema: el
|
||||||
ESTADOS_SOPORTE,
|
* botón abre el formulario oficial en una pestaña nueva.
|
||||||
MAX_ADJUNTOS_SOPORTE,
|
*/
|
||||||
PRIORIDADES_SOPORTE,
|
|
||||||
cambiarEstadoSolicitud,
|
const URL_FORMULARIO_SOPORTE = 'https://wkf.ms/3T5NyTY'
|
||||||
crearSolicitudSoporte,
|
|
||||||
descargarArchivoSolicitud,
|
|
||||||
listarSolicitudesSoporte,
|
|
||||||
nombreArchivoSoporte,
|
|
||||||
validarAdjuntoSoporte,
|
|
||||||
type EstadoSoporte,
|
|
||||||
type SolicitudSoporte,
|
|
||||||
} from '../services/soporte'
|
|
||||||
import { getSesion } from '../services/auth'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onVolver: () => void
|
onVolver: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClase =
|
|
||||||
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
|
|
||||||
|
|
||||||
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
|
||||||
|
|
||||||
const ETIQUETA_ESTADO: Record<string, string> = {
|
|
||||||
ABIERTA: 'Abierta',
|
|
||||||
EN_PROCESO: 'En proceso',
|
|
||||||
CERRADA: 'Cerrada',
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPCIONES_ESTADO = [
|
|
||||||
{ valor: '', etiqueta: 'Todos los estatus' },
|
|
||||||
...ESTADOS_SOPORTE.map((e) => ({ valor: e, etiqueta: ETIQUETA_ESTADO[e] ?? e })),
|
|
||||||
]
|
|
||||||
|
|
||||||
const OPCIONES_DEPARTAMENTO = [
|
|
||||||
{ valor: '', etiqueta: 'Todos los departamentos' },
|
|
||||||
...DEPARTAMENTOS_SOPORTE.map((d) => ({ valor: d, etiqueta: d })),
|
|
||||||
]
|
|
||||||
|
|
||||||
const CLASE_ESTADO: Record<string, string> = {
|
|
||||||
ABIERTA: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
|
|
||||||
EN_PROCESO: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
|
|
||||||
CERRADA: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fecha de hoy en formato YYYY-MM-DD (hora local) */
|
|
||||||
function hoyISO(): string {
|
|
||||||
const hoy = new Date()
|
|
||||||
const mes = String(hoy.getMonth() + 1).padStart(2, '0')
|
|
||||||
const dia = String(hoy.getDate()).padStart(2, '0')
|
|
||||||
return `${hoy.getFullYear()}-${mes}-${dia}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SoportePage({ onVolver }: Props) {
|
export function SoportePage({ onVolver }: Props) {
|
||||||
const sesion = getSesion()
|
|
||||||
const puedeGestionar = sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager'
|
|
||||||
const [titulo, setTitulo] = useState('')
|
|
||||||
const [descripcion, setDescripcion] = useState('')
|
|
||||||
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
|
|
||||||
const [prioridad, setPrioridad] = useState<string>('Media')
|
|
||||||
const [responsable, setResponsable] = useState('')
|
|
||||||
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
|
|
||||||
const [fechaLimite, setFechaLimite] = useState('')
|
|
||||||
const [notas, setNotas] = useState('')
|
|
||||||
const [email, setEmail] = useState('')
|
|
||||||
const [archivos, setArchivos] = useState<File[]>([])
|
|
||||||
const [errorArchivos, setErrorArchivos] = useState('')
|
|
||||||
const [enviando, setEnviando] = useState(false)
|
|
||||||
const [aviso, setAviso] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
|
|
||||||
const [cargando, setCargando] = useState(true)
|
|
||||||
const [filtroEstado, setFiltroEstado] = useState('')
|
|
||||||
const [filtroDepartamento, setFiltroDepartamento] = useState('')
|
|
||||||
const [ocupado, setOcupado] = useState('')
|
|
||||||
|
|
||||||
const cargar = useCallback(async () => {
|
|
||||||
setCargando(true)
|
|
||||||
try {
|
|
||||||
setSolicitudes(
|
|
||||||
await listarSolicitudesSoporte({
|
|
||||||
...(filtroEstado ? { estado: filtroEstado } : {}),
|
|
||||||
...(filtroDepartamento ? { departamento: filtroDepartamento } : {}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// La lista es secundaria: el form sigue usable si el listado falla
|
|
||||||
} finally {
|
|
||||||
setCargando(false)
|
|
||||||
}
|
|
||||||
}, [filtroEstado, filtroDepartamento])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void cargar()
|
|
||||||
}, [cargar])
|
|
||||||
|
|
||||||
const agregarArchivos = (lista: FileList | null) => {
|
|
||||||
if (lista === null) return
|
|
||||||
const nuevos: File[] = []
|
|
||||||
const rechazados: string[] = []
|
|
||||||
for (const archivo of Array.from(lista)) {
|
|
||||||
const problema = validarAdjuntoSoporte(archivo)
|
|
||||||
if (problema) rechazados.push(problema)
|
|
||||||
else nuevos.push(archivo)
|
|
||||||
}
|
|
||||||
const combinados = [...archivos, ...nuevos]
|
|
||||||
if (combinados.length > MAX_ADJUNTOS_SOPORTE) {
|
|
||||||
rechazados.push(`Máximo ${MAX_ADJUNTOS_SOPORTE} archivos por solicitud.`)
|
|
||||||
}
|
|
||||||
setArchivos(combinados.slice(0, MAX_ADJUNTOS_SOPORTE))
|
|
||||||
setErrorArchivos(rechazados.join(' '))
|
|
||||||
}
|
|
||||||
|
|
||||||
const enviar = async (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setAviso('')
|
|
||||||
setError('')
|
|
||||||
if (!titulo.trim() || !descripcion.trim()) {
|
|
||||||
setError('El título y la descripción son obligatorios.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEnviando(true)
|
|
||||||
try {
|
|
||||||
const creada = await crearSolicitudSoporte({
|
|
||||||
titulo: titulo.trim(),
|
|
||||||
descripcion: descripcion.trim(),
|
|
||||||
departamento: departamento as (typeof DEPARTAMENTOS_SOPORTE)[number],
|
|
||||||
prioridad: prioridad as (typeof PRIORIDADES_SOPORTE)[number],
|
|
||||||
...(responsable.trim() ? { responsable: responsable.trim() } : {}),
|
|
||||||
fechaSolicitud,
|
|
||||||
...(fechaLimite ? { fechaLimite } : {}),
|
|
||||||
...(notas.trim() ? { notas: notas.trim() } : {}),
|
|
||||||
...(sesion?.usuario.nombre ? { solicitante: sesion.usuario.nombre } : {}),
|
|
||||||
...(email.trim() ? { email: email.trim() } : {}),
|
|
||||||
...(archivos.length > 0 ? { archivos } : {}),
|
|
||||||
})
|
|
||||||
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
|
|
||||||
setTitulo('')
|
|
||||||
setDescripcion('')
|
|
||||||
setResponsable('')
|
|
||||||
setNotas('')
|
|
||||||
setArchivos([])
|
|
||||||
setErrorArchivos('')
|
|
||||||
setFechaLimite('')
|
|
||||||
await cargar()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo registrar la solicitud.')
|
|
||||||
} finally {
|
|
||||||
setEnviando(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const descargar = async (solicitud: SolicitudSoporte, indice: number) => {
|
|
||||||
const ruta = solicitud.archivos[indice] ?? ''
|
|
||||||
setError('')
|
|
||||||
setOcupado(`${solicitud.id}:${indice}`)
|
|
||||||
try {
|
|
||||||
const blob = await descargarArchivoSolicitud(solicitud.id, indice)
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const enlace = document.createElement('a')
|
|
||||||
enlace.href = url
|
|
||||||
enlace.download = nombreArchivoSoporte(ruta)
|
|
||||||
document.body.appendChild(enlace)
|
|
||||||
enlace.click()
|
|
||||||
enlace.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo descargar el archivo.')
|
|
||||||
} finally {
|
|
||||||
setOcupado('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cambiarEstado = async (solicitud: SolicitudSoporte, estado: string) => {
|
|
||||||
setAviso('')
|
|
||||||
setError('')
|
|
||||||
setOcupado(solicitud.id)
|
|
||||||
try {
|
|
||||||
const actualizada = await cambiarEstadoSolicitud(solicitud.id, estado as EstadoSoporte)
|
|
||||||
setSolicitudes((prev) => prev.map((s) => (s.id === actualizada.id ? actualizada : s)))
|
|
||||||
setAviso(`"${actualizada.titulo}" ahora está ${ETIQUETA_ESTADO[actualizada.estado] ?? actualizada.estado}.`)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'No se pudo cambiar el estado.')
|
|
||||||
} finally {
|
|
||||||
setOcupado('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
|
<div className="mx-auto max-w-2xl px-4 py-10">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -204,281 +25,31 @@ export function SoportePage({ onVolver }: Props) {
|
|||||||
Soporte
|
Soporte
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
Registra una solicitud para los departamentos internos y dale seguimiento desde esta misma
|
|
||||||
pantalla.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Formulario */}
|
<div className="mt-8 rounded-2xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
<form onSubmit={(e) => void enviar(e)} noValidate className="mt-8 max-w-3xl">
|
<p className="text-4xl" aria-hidden="true">
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
🛠️
|
||||||
<label htmlFor="soporte-titulo" className="block sm:col-span-2">
|
</p>
|
||||||
<span className={labelClase}>Título<span className="text-red-700"> *</span></span>
|
<h2 className="mt-4 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
<input
|
Solicitudes internas
|
||||||
id="soporte-titulo"
|
|
||||||
type="text"
|
|
||||||
value={titulo}
|
|
||||||
onChange={(e) => setTitulo(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-descripcion" className="block sm:col-span-2">
|
|
||||||
<span className={labelClase}>Descripción<span className="text-red-700"> *</span></span>
|
|
||||||
<textarea
|
|
||||||
id="soporte-descripcion"
|
|
||||||
rows={4}
|
|
||||||
value={descripcion}
|
|
||||||
onChange={(e) => setDescripcion(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-departamento" className="block">
|
|
||||||
<span className={labelClase}>Departamento</span>
|
|
||||||
<select
|
|
||||||
id="soporte-departamento"
|
|
||||||
value={departamento}
|
|
||||||
onChange={(e) => setDepartamento(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
>
|
|
||||||
{DEPARTAMENTOS_SOPORTE.map((d) => (
|
|
||||||
<option key={d} value={d}>{d}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-prioridad" className="block">
|
|
||||||
<span className={labelClase}>Prioridad</span>
|
|
||||||
<select
|
|
||||||
id="soporte-prioridad"
|
|
||||||
value={prioridad}
|
|
||||||
onChange={(e) => setPrioridad(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
>
|
|
||||||
{PRIORIDADES_SOPORTE.map((p) => (
|
|
||||||
<option key={p} value={p}>{p}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-responsable" className="block">
|
|
||||||
<span className={labelClase}>Responsable</span>
|
|
||||||
<input
|
|
||||||
id="soporte-responsable"
|
|
||||||
type="text"
|
|
||||||
value={responsable}
|
|
||||||
onChange={(e) => setResponsable(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-fecha-solicitud" className="block">
|
|
||||||
<span className={labelClase}>Fecha de solicitud</span>
|
|
||||||
<input
|
|
||||||
id="soporte-fecha-solicitud"
|
|
||||||
type="date"
|
|
||||||
value={fechaSolicitud}
|
|
||||||
onChange={(e) => setFechaSolicitud(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-email" className="block">
|
|
||||||
<span className={labelClase}>Correo del solicitante</span>
|
|
||||||
<input
|
|
||||||
id="soporte-email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="nombre@frasa.mx"
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-fecha-limite" className="block">
|
|
||||||
<span className={labelClase}>Fecha límite</span>
|
|
||||||
<input
|
|
||||||
id="soporte-fecha-limite"
|
|
||||||
type="date"
|
|
||||||
value={fechaLimite}
|
|
||||||
onChange={(e) => setFechaLimite(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-archivos" className="block">
|
|
||||||
<span className={labelClase}>Archivos (PDF, JPG o PNG · máx 10 MB)</span>
|
|
||||||
<input
|
|
||||||
id="soporte-archivos"
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="application/pdf,image/jpeg,image/png"
|
|
||||||
onChange={(e) => agregarArchivos(e.target.files)}
|
|
||||||
className={`${inputClase} file:mr-3 file:rounded-md file:border-0 file:bg-[#F1F5F9] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-[#374151]`}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label htmlFor="soporte-notas" className="block sm:col-span-2">
|
|
||||||
<span className={labelClase}>Notas</span>
|
|
||||||
<textarea
|
|
||||||
id="soporte-notas"
|
|
||||||
rows={2}
|
|
||||||
value={notas}
|
|
||||||
onChange={(e) => setNotas(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{archivos.length > 0 && (
|
|
||||||
<ul className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{archivos.map((archivo, i) => (
|
|
||||||
<li
|
|
||||||
key={`${archivo.name}-${i}`}
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-1.5 text-xs text-[#374151] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
|
|
||||||
>
|
|
||||||
{archivo.name}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Quitar ${archivo.name}`}
|
|
||||||
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
|
|
||||||
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{errorArchivos && (
|
|
||||||
<p role="alert" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
|
|
||||||
{errorArchivos}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{aviso && (
|
|
||||||
<p role="status" className="mt-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-xs font-medium text-green-700">
|
|
||||||
{aviso}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={enviando}
|
|
||||||
className="mt-6 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:opacity-50"
|
|
||||||
>
|
|
||||||
{enviando ? 'Registrando…' : 'Enviar solicitud'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Listado de solicitudes */}
|
|
||||||
<section aria-label={puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'} className="mt-12 max-w-3xl">
|
|
||||||
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
|
||||||
{puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
|
||||||
{/* Filtros del listado */}
|
Las solicitudes de soporte y los reportes a otros departamentos ahora se registran en el
|
||||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
formulario oficial. Al abrirlo podrás describir tu solicitud, adjuntar archivos y elegir
|
||||||
<label htmlFor="soporte-filtro-estado" className="block">
|
el departamento responsable.
|
||||||
<span className={labelClase}>Filtrar por estatus</span>
|
</p>
|
||||||
<select
|
<a
|
||||||
id="soporte-filtro-estado"
|
href={URL_FORMULARIO_SOPORTE}
|
||||||
value={filtroEstado}
|
target="_blank"
|
||||||
onChange={(e) => setFiltroEstado(e.target.value)}
|
rel="noopener noreferrer"
|
||||||
className={inputClase}
|
className="mt-6 inline-block rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
||||||
>
|
>
|
||||||
{OPCIONES_ESTADO.map((o) => (
|
Abrir formulario de soporte ↗
|
||||||
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
|
</a>
|
||||||
))}
|
<p className="mt-4 text-xs text-[#94A3B8]">
|
||||||
</select>
|
Se abre en una pestaña nueva: {URL_FORMULARIO_SOPORTE}
|
||||||
</label>
|
</p>
|
||||||
<label htmlFor="soporte-filtro-departamento" className="block">
|
</div>
|
||||||
<span className={labelClase}>Filtrar por departamento</span>
|
</div>
|
||||||
<select
|
|
||||||
id="soporte-filtro-departamento"
|
|
||||||
value={filtroDepartamento}
|
|
||||||
onChange={(e) => setFiltroDepartamento(e.target.value)}
|
|
||||||
className={inputClase}
|
|
||||||
>
|
|
||||||
{OPCIONES_DEPARTAMENTO.map((o) => (
|
|
||||||
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{cargando ? (
|
|
||||||
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
|
|
||||||
) : solicitudes.length === 0 ? (
|
|
||||||
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
{filtroEstado || filtroDepartamento
|
|
||||||
? 'Ninguna solicitud coincide con los filtros.'
|
|
||||||
: 'Aún no hay solicitudes registradas.'}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-4 space-y-3">
|
|
||||||
{solicitudes.map((s) => (
|
|
||||||
<li
|
|
||||||
key={s.id}
|
|
||||||
className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{s.titulo}</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
|
|
||||||
>
|
|
||||||
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
|
|
||||||
</span>
|
|
||||||
{puedeGestionar && (
|
|
||||||
<select
|
|
||||||
aria-label={`Cambiar estatus de ${s.titulo}`}
|
|
||||||
value={s.estado}
|
|
||||||
disabled={ocupado === s.id}
|
|
||||||
onChange={(e) => void cambiarEstado(s, e.target.value)}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
|
|
||||||
>
|
|
||||||
{ESTADOS_SOPORTE.map((e) => (
|
|
||||||
<option key={e} value={e}>{ETIQUETA_ESTADO[e] ?? e}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
||||||
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'}
|
|
||||||
{s.solicitanteEmail ? ` (${s.solicitanteEmail})` : ''} ·{' '}
|
|
||||||
{new Date(s.fechaSolicitud).toLocaleDateString('es-MX')}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 text-sm text-[#374151] dark:text-[#CBD5E1]">{s.descripcion}</p>
|
|
||||||
{s.archivos.length > 0 && (
|
|
||||||
<ul className="mt-2 flex flex-wrap gap-2">
|
|
||||||
{s.archivos.map((ruta, i) => (
|
|
||||||
<li key={`${ruta}-${i}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void descargar(s, i)}
|
|
||||||
disabled={ocupado === `${s.id}:${i}`}
|
|
||||||
className="rounded-lg border border-[#D1D5DB] bg-[#F9FAFB] px-2.5 py-1 text-xs text-[#374151] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
|
|
||||||
>
|
|
||||||
⬇ {nombreArchivoSoporte(ruta)}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react'
|
import { X, User, Calendar, CheckSquare, FileText, Users, Search, ClipboardList } from 'lucide-react'
|
||||||
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
|
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia, AfluenciaPerfil } from '../lib/mi-dia'
|
||||||
import { formatoFechaCorta, formatoFechaHora, urlWhatsApp } from '../lib/mi-dia'
|
import { formatoFechaCorta, formatoFechaHora, urlWhatsApp } from '../lib/mi-dia'
|
||||||
import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
|
import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
|
||||||
|
import { fetchAfluencia } from '../services/mi-dia'
|
||||||
import { getSesion } from '../services/auth'
|
import { getSesion } from '../services/auth'
|
||||||
|
|
||||||
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
|
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
|
||||||
@@ -21,7 +22,33 @@ function esOportunidad(item: LeadItem): item is OportunidadMiDia {
|
|||||||
return 'modeloInteres' in item
|
return 'modeloInteres' in item
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros'
|
type TabId = 'info' | 'cita' | 'afluencia' | 'tareas' | 'notas' | 'miembros'
|
||||||
|
|
||||||
|
/** Etiquetas legibles de los campos del perfil de afluencia (bitácora) */
|
||||||
|
const ETIQUETAS_AFLUENCIA: Record<string, string> = {
|
||||||
|
nombre: 'Nombre',
|
||||||
|
telefono: 'Teléfono',
|
||||||
|
email: 'Email',
|
||||||
|
origen: 'Origen',
|
||||||
|
desarrollo: 'Desarrollo',
|
||||||
|
modelo: 'Modelo',
|
||||||
|
medioContacto: 'Medio de contacto',
|
||||||
|
numDependientes: 'No. de dependientes',
|
||||||
|
edad: 'Edad',
|
||||||
|
estadoCivil: 'Estado civil',
|
||||||
|
tipoEmpleo: 'Tipo de empleo',
|
||||||
|
ocupacion: 'Ocupación',
|
||||||
|
rangoSalarial: 'Rango salarial',
|
||||||
|
credito: 'Crédito',
|
||||||
|
tipoEconomia: 'Tipo de economía',
|
||||||
|
califica: 'Califica',
|
||||||
|
loQuierePara: 'Lo quiere para',
|
||||||
|
notas: 'Notas',
|
||||||
|
unidadCodigo: 'Unidad',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claves internas que no se muestran como campo del perfil */
|
||||||
|
const CLAVES_AFLUENCIA_OCULTAS = ['fechaCita', 'oportunidadId']
|
||||||
|
|
||||||
const CLASE_ACCION_RAPIDA =
|
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'
|
||||||
@@ -29,6 +56,7 @@ const CLASE_ACCION_RAPIDA =
|
|||||||
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
|
||||||
{ id: 'info', titulo: 'Información', icono: User },
|
{ id: 'info', titulo: 'Información', icono: User },
|
||||||
{ id: 'cita', titulo: 'Cita', icono: Calendar },
|
{ id: 'cita', titulo: 'Cita', icono: Calendar },
|
||||||
|
{ id: 'afluencia', titulo: 'Afluencia', icono: ClipboardList },
|
||||||
{ 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: 'Miembros', icono: Users },
|
{ id: 'miembros', titulo: 'Miembros', icono: Users },
|
||||||
@@ -63,6 +91,20 @@ export default function DetalleLeadModal({
|
|||||||
// --- Cita ---
|
// --- Cita ---
|
||||||
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
|
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
|
||||||
const [guardandoCita, setGuardandoCita] = useState(false)
|
const [guardandoCita, setGuardandoCita] = useState(false)
|
||||||
|
// --- Afluencia (pestaña de la bitácora) ---
|
||||||
|
const [afluencia, setAfluencia] = useState<AfluenciaPerfil | null>(null)
|
||||||
|
const [cargandoAfluencia, setCargandoAfluencia] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== 'afluencia') return
|
||||||
|
let vivo = true
|
||||||
|
setCargandoAfluencia(true)
|
||||||
|
fetchAfluencia(item.id)
|
||||||
|
.then((a) => { if (vivo) setAfluencia(a) })
|
||||||
|
.catch(() => { if (vivo) setAfluencia(null) })
|
||||||
|
.finally(() => { if (vivo) setCargandoAfluencia(false) })
|
||||||
|
return () => { vivo = false }
|
||||||
|
}, [tab, item.id])
|
||||||
|
|
||||||
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
|
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
|
||||||
async function guardarCita(e: FormEvent) {
|
async function guardarCita(e: FormEvent) {
|
||||||
@@ -231,6 +273,35 @@ export default function DetalleLeadModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'afluencia' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Afluencia</h3>
|
||||||
|
{afluencia?.afluenciaCompleta && (
|
||||||
|
<span className="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
Afluencia completa
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{cargandoAfluencia ? (
|
||||||
|
<p className="text-sm text-[#94A3B8]">Cargando afluencia…</p>
|
||||||
|
) : !afluencia || Object.keys(afluencia.perfil).length === 0 ? (
|
||||||
|
<p className="text-sm text-[#94A3B8]">
|
||||||
|
Aún no se ha capturado la afluencia de este cliente. Se llena desde el botón
|
||||||
|
"Afluencia →" de Mis oportunidades.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{Object.entries(afluencia.perfil)
|
||||||
|
.filter(([clave]) => !CLAVES_AFLUENCIA_OCULTAS.includes(clave))
|
||||||
|
.map(([clave, valor]) => (
|
||||||
|
<InfoCampo key={clave} etiqueta={ETIQUETAS_AFLUENCIA[clave] ?? clave} valor={valor} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'tareas' && (
|
{tab === 'tareas' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>
|
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>
|
||||||
|
|||||||
@@ -141,14 +141,15 @@ export function aplicarFiltros(
|
|||||||
if (!valor.includes(busqueda)) return false
|
if (!valor.includes(busqueda)) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desarrollo
|
// Desarrollo (comparación normalizada: el catálogo y los datos pueden
|
||||||
|
// variar en mayúsculas/espacios — era la causa principal de filtros rotos)
|
||||||
if (filtros.desarrollo !== '' && accessor.desarrollo) {
|
if (filtros.desarrollo !== '' && accessor.desarrollo) {
|
||||||
if (accessor.desarrollo(item) !== filtros.desarrollo) return false
|
if (accessor.desarrollo(item).trim().toUpperCase() !== filtros.desarrollo.trim().toUpperCase()) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Origen
|
// Origen (misma normalización que desarrollo)
|
||||||
if (filtros.origen !== '' && accessor.origen) {
|
if (filtros.origen !== '' && accessor.origen) {
|
||||||
if (accessor.origen(item) !== filtros.origen) return false
|
if (accessor.origen(item).trim().toUpperCase() !== filtros.origen.trim().toUpperCase()) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estado (comparación case-insensitive: los labels pueden variar en mayúsculas)
|
// Estado (comparación case-insensitive: los labels pueden variar en mayúsculas)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ describe('urlCotizadorParaOportunidad', () => {
|
|||||||
etapa: 'Afluencia',
|
etapa: 'Afluencia',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modeloInteres: 'SOHO',
|
modeloInteres: 'SOHO',
|
||||||
|
afluenciaCompleta: true,
|
||||||
vendedor: 'Martin Zavala',
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: 'U-9',
|
unidadCodigo: 'U-9',
|
||||||
origen: 'FACEBOOK',
|
origen: 'FACEBOOK',
|
||||||
@@ -120,6 +121,7 @@ describe('urlAfluenciaParaOportunidad', () => {
|
|||||||
etapa: 'Agendo Cita',
|
etapa: 'Agendo Cita',
|
||||||
desarrollo: 'SENDA',
|
desarrollo: 'SENDA',
|
||||||
modeloInteres: 'SOHO',
|
modeloInteres: 'SOHO',
|
||||||
|
afluenciaCompleta: false,
|
||||||
vendedor: 'Martin Zavala',
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: '',
|
unidadCodigo: '',
|
||||||
origen: '',
|
origen: '',
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface OportunidadMiDia {
|
|||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
||||||
fechaCita?: string
|
fechaCita?: string
|
||||||
|
/** ¿Afluencia con perfil completo? Sin esto NO puede avanzar a 'Aparto' */
|
||||||
|
afluenciaCompleta: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apartado asignado al vendedor (tab "Mis apartados") */
|
/** Apartado asignado al vendedor (tab "Mis apartados") */
|
||||||
@@ -275,6 +277,17 @@ export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
|
|||||||
return `/quote/?${params.toString()}`
|
return `/quote/?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perfil de afluencia capturado de un lead/oportunidad/apartado
|
||||||
|
* (GET /api/afluencias/:leadId). Solo valores de texto no vacíos.
|
||||||
|
*/
|
||||||
|
export interface AfluenciaPerfil {
|
||||||
|
leadId: string
|
||||||
|
nombre: string
|
||||||
|
perfil: Record<string, string>
|
||||||
|
afluenciaCompleta: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
|
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
|
||||||
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil
|
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil
|
||||||
|
|||||||
@@ -250,11 +250,18 @@ function FilaOportunidad({
|
|||||||
>
|
>
|
||||||
Cotizar →
|
Cotizar →
|
||||||
</button>
|
</button>
|
||||||
{/* 4. Aparto: marca la etapa (verde) */}
|
{/* 4. Aparto: marca la etapa (verde). REQUIERE afluencia completa:
|
||||||
|
sin perfil capturado no se puede apartar (regla de negocio) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando}
|
disabled={enviando}
|
||||||
onClick={() => onEtapa('Aparto')}
|
onClick={() => {
|
||||||
|
if (!op.afluenciaCompleta) {
|
||||||
|
toast.error('Primero debes capturar la afluencia del cliente (botón Afluencia →)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEtapa('Aparto')
|
||||||
|
}}
|
||||||
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50"
|
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Aparto
|
Aparto
|
||||||
@@ -368,11 +375,15 @@ function FilaApartado({
|
|||||||
enviando,
|
enviando,
|
||||||
}: {
|
}: {
|
||||||
apartado: ApartadoMiDia
|
apartado: ApartadoMiDia
|
||||||
onFase: (fase: FaseApartado) => void
|
onFase: (fase: FaseApartado, fecha?: string) => void
|
||||||
onVerDetalle?: () => void
|
onVerDetalle?: () => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
}) {
|
}) {
|
||||||
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
const [docsAbiertos, setDocsAbiertos] = useState(false)
|
||||||
|
// 'Cita Clientes': al primer toque pide la fecha y hora; al confirmar agenda
|
||||||
|
// la cita en el calendario del vendedor (ActividadLocal en el API)
|
||||||
|
const [conCita, setConCita] = useState(false)
|
||||||
|
const [fechaCita, setFechaCita] = useState('')
|
||||||
return (
|
return (
|
||||||
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -424,6 +435,21 @@ function FilaApartado({
|
|||||||
window.open(`/client/?apartado=${apartado.id}`, '_blank')
|
window.open(`/client/?apartado=${apartado.id}`, '_blank')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Cita Clientes: al primer toque pide fecha y hora; al confirmar
|
||||||
|
// agenda la cita en el calendario (ActividadLocal del API)
|
||||||
|
if (fase === 'Cita Clientes') {
|
||||||
|
if (!conCita) {
|
||||||
|
setConCita(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (fechaCita === '') {
|
||||||
|
toast.error('Elige la fecha y hora de la cita')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onFase(fase, fechaCita)
|
||||||
|
setConCita(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
onFase(fase)
|
onFase(fase)
|
||||||
}}
|
}}
|
||||||
className={
|
className={
|
||||||
@@ -438,9 +464,22 @@ function FilaApartado({
|
|||||||
: CLASE_BOTON_ACCION
|
: CLASE_BOTON_ACCION
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{fase === 'Alta Enkontrol' ? 'Alta Enkontrol →' : ETIQUETA_FASE_APARTADO[fase]}
|
{fase === 'Alta Enkontrol'
|
||||||
|
? 'Alta Enkontrol →'
|
||||||
|
: fase === 'Cita Clientes' && conCita
|
||||||
|
? 'Confirmar cita'
|
||||||
|
: ETIQUETA_FASE_APARTADO[fase]}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{conCita && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
aria-label="Fecha y hora de la cita del cliente"
|
||||||
|
value={fechaCita}
|
||||||
|
onChange={(e) => setFechaCita(e.target.value)}
|
||||||
|
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDocsAbiertos((v) => !v)}
|
onClick={() => setDocsAbiertos((v) => !v)}
|
||||||
@@ -1140,6 +1179,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
const leadsFiltrados = resumen
|
const leadsFiltrados = resumen
|
||||||
? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead' && coincideVendedor(l.vendedor)), filtros, {
|
? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead' && coincideVendedor(l.vendedor)), filtros, {
|
||||||
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
|
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
|
||||||
|
desarrollo: (l) => l.desarrollo ?? '',
|
||||||
origen: (l) => l.origen,
|
origen: (l) => l.origen,
|
||||||
estado: (l) => l.estado,
|
estado: (l) => l.estado,
|
||||||
fecha: (l) => l.updatedAt.slice(0, 10),
|
fecha: (l) => l.updatedAt.slice(0, 10),
|
||||||
@@ -1149,13 +1189,16 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
? aplicarFiltros(resumen.misOportunidades.items.filter((o) => coincideVendedor(o.vendedor)), filtros, {
|
? aplicarFiltros(resumen.misOportunidades.items.filter((o) => coincideVendedor(o.vendedor)), filtros, {
|
||||||
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
|
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
|
||||||
desarrollo: (o) => o.desarrollo,
|
desarrollo: (o) => o.desarrollo,
|
||||||
|
origen: (o) => o.origen,
|
||||||
estado: (o) => o.etapa,
|
estado: (o) => o.etapa,
|
||||||
fecha: (o) => o.updatedAt.slice(0, 10),
|
fecha: (o) => o.updatedAt.slice(0, 10),
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
const apartadosFiltrados = resumen
|
const apartadosFiltrados = resumen
|
||||||
? aplicarFiltros(resumen.misApartados.filter((a) => coincideVendedor(a.vendedor)), filtros, {
|
? aplicarFiltros(resumen.misApartados.filter((a) => coincideVendedor(a.vendedor)), filtros, {
|
||||||
texto: (a) => a.nombre,
|
texto: (a) => `${a.nombre} ${a.telefono} ${a.email}`,
|
||||||
|
desarrollo: (a) => a.desarrollo,
|
||||||
|
origen: (a) => a.origen,
|
||||||
estado: (a) => a.estado,
|
estado: (a) => a.estado,
|
||||||
fecha: (a) => a.fechaLimite,
|
fecha: (a) => a.fechaLimite,
|
||||||
})
|
})
|
||||||
@@ -1184,7 +1227,9 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {
|
const escrituracionFiltrados = aplicarFiltros(enEscrituracion, filtrosPorTab.escrituracion, {
|
||||||
texto: (a) => a.nombre,
|
texto: (a) => `${a.nombre} ${a.telefono} ${a.email}`,
|
||||||
|
desarrollo: (a) => a.desarrollo,
|
||||||
|
origen: (a) => a.origen,
|
||||||
estado: (a) => a.estado,
|
estado: (a) => a.estado,
|
||||||
fecha: (a) => a.fechaLimite,
|
fecha: (a) => a.fechaLimite,
|
||||||
})
|
})
|
||||||
@@ -1422,6 +1467,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
// No retroceder etapa al soltar en "Agendo Cita" una
|
// No retroceder etapa al soltar en "Agendo Cita" una
|
||||||
// op que ya está en Cotizador/Aparto
|
// op que ya está en Cotizador/Aparto
|
||||||
const op = resumen.misOportunidades.items.find((o) => o.id === id)
|
const op = resumen.misOportunidades.items.find((o) => o.id === id)
|
||||||
|
// Gate de negocio: sin afluencia completa no se aparta
|
||||||
|
if (estado === 'Aparto' && op && !op.afluenciaCompleta) {
|
||||||
|
toast.error('Primero debes capturar la afluencia del cliente (botón Afluencia →)')
|
||||||
|
return
|
||||||
|
}
|
||||||
const destino =
|
const destino =
|
||||||
estado === 'Agendo Cita' && op && (op.etapa === 'Cotizador' || op.etapa === 'Aparto')
|
estado === 'Agendo Cita' && op && (op.etapa === 'Cotizador' || op.etapa === 'Aparto')
|
||||||
? op.etapa
|
? op.etapa
|
||||||
@@ -1482,7 +1532,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
apartado={a}
|
apartado={a}
|
||||||
enviando={enviando}
|
enviando={enviando}
|
||||||
onVerDetalle={() => setLeadSeleccionado(a)}
|
onVerDetalle={() => setLeadSeleccionado(a)}
|
||||||
onFase={(fase) => {
|
onFase={(fase, fecha) => {
|
||||||
if (fase === 'Cancelacion Apartado') {
|
if (fase === 'Cancelacion Apartado') {
|
||||||
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
|
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
|
||||||
setApartadoCancelando({ id: a.id, nombre: a.nombre })
|
setApartadoCancelando({ id: a.id, nombre: a.nombre })
|
||||||
@@ -1493,7 +1543,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
|
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')
|
void ejecutar(
|
||||||
|
() => cambiarEstadoApartado(a.id, fase, undefined, undefined, undefined, fecha),
|
||||||
|
'No se pudo actualizar el apartado',
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const opFake: OportunidadMiDia = {
|
|||||||
etapa: 'Agendo Cita',
|
etapa: 'Agendo Cita',
|
||||||
desarrollo: 'DIAGONAL',
|
desarrollo: 'DIAGONAL',
|
||||||
modeloInteres: 'Modelo A',
|
modeloInteres: 'Modelo A',
|
||||||
|
afluenciaCompleta: false,
|
||||||
vendedor: 'Martin Zavala',
|
vendedor: 'Martin Zavala',
|
||||||
unidadCodigo: 'D-101',
|
unidadCodigo: 'D-101',
|
||||||
origen: '',
|
origen: '',
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function cambiarEstadoApartado(
|
|||||||
motivo?: string,
|
motivo?: string,
|
||||||
subcategoria?: string,
|
subcategoria?: string,
|
||||||
comentario?: string,
|
comentario?: string,
|
||||||
|
fecha?: string,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return fetchJson(`/apartados/${itemId}/estado`, {
|
return fetchJson(`/apartados/${itemId}/estado`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -103,10 +104,16 @@ export function cambiarEstadoApartado(
|
|||||||
...(motivo !== undefined && motivo !== '' ? { motivo } : {}),
|
...(motivo !== undefined && motivo !== '' ? { motivo } : {}),
|
||||||
...(subcategoria !== undefined && subcategoria !== '' ? { subcategoria } : {}),
|
...(subcategoria !== undefined && subcategoria !== '' ? { subcategoria } : {}),
|
||||||
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
|
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
|
||||||
|
...(fecha !== undefined && fecha !== '' ? { fecha } : {}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /afluencias/:leadId — perfil de afluencia capturado (pestaña de la bitácora) */
|
||||||
|
export async function fetchAfluencia(leadId: string): Promise<import('../lib/mi-dia').AfluenciaPerfil> {
|
||||||
|
return fetchJson(`/afluencias/${leadId}`)
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /leads/:itemId/mover — mover lead a otro tablero (solo admin) */
|
/** POST /leads/:itemId/mover — mover lead a otro tablero (solo admin) */
|
||||||
export async function moverLead(itemId: string, board: 'LEADS' | 'OPORTUNIDADES'): Promise<void> {
|
export async function moverLead(itemId: string, board: 'LEADS' | 'OPORTUNIDADES'): Promise<void> {
|
||||||
const res = await fetch(`${API_URL}/leads/${itemId}/mover`, conAuth({
|
const res = await fetch(`${API_URL}/leads/${itemId}/mover`, conAuth({
|
||||||
|
|||||||
Reference in New Issue
Block a user