feat(mi-dia): tabs Mis oportunidades/Mis apartados con fases y cotizador vinculado

- Resumen Mi día: misOportunidades.items (lista de trabajo con perfil y
  último cambio) y misApartados (Vendedor = yo, estados no finales)
- Nuevos endpoints: POST /oportunidades/:id/etapa (Agendo Cita, Afluencia,
  Cotizador, Aparto, No Asistio, Abandono; con fecha de cita) y
  POST /apartados/:id/estado (pipeline Enkontrol: Pago Apartado →
  Firma Cliente (Venta) / Cancelacion Apartado); ambos mueven el item al
  grupo Monday correspondiente
- Mi día: tabs Mis leads / Mis oportunidades / Mis apartados; botón
  Cotizar abre /quote con el cliente prellenado y la oportunidad ligada
- Cotizador: prefill desde query params (cliente, teléfono, email,
  desarrollo, unidad) y envío de oportunidadId; al guardar, el API
  actualiza ESE item (Precio final, Tipo propuesta, etapa Cotizador)
  en vez de buscar por teléfono o crear duplicado
- grupos-estado: 'Cancelacion Apartado' → Historico
- Estados finales de apartado: agrega Firma Cliente (Venta) y
  Cancelacion Apartado
- Tests: 455 API + 14 mi-dia + 88 cotizador, todo en verde
This commit is contained in:
2026-09-04 21:54:44 +00:00
parent a555f69dcd
commit 1963c06d93
17 changed files with 953 additions and 35 deletions

View File

@@ -4,10 +4,12 @@ import {
esRolConEquipo,
formatoFechaCorta,
hoyLocal,
urlCotizadorParaOportunidad,
DESARROLLOS_LEAD,
MOTIVOS_NO_COMPRA,
ORIGENES_LEAD,
textoDiasRestantes,
type OportunidadMiDia,
} from './mi-dia'
describe('catálogos', () => {
@@ -67,3 +69,41 @@ describe('hoyLocal', () => {
expect(hoyLocal()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
})
describe('urlCotizadorParaOportunidad', () => {
const opBase: OportunidadMiDia = {
id: 'opp-1',
nombre: 'María Pérez',
telefono: '4771234567',
email: 'maria@correo.com',
etapa: 'Afluencia',
desarrollo: 'SENDA',
modeloInteres: 'SOHO',
unidadCodigo: 'U-9',
updatedAt: '2026-09-04T10:00:00Z',
ultimoCambio: null,
}
it('prellena cliente, contacto, desarrollo y unidad, y vincula la oportunidad', () => {
const url = urlCotizadorParaOportunidad(opBase)
const params = new URLSearchParams(url.replace('/quote/?', ''))
expect(url.startsWith('/quote/?')).toBe(true)
expect(params.get('cliente')).toBe('María Pérez')
expect(params.get('telefono')).toBe('4771234567')
expect(params.get('email')).toBe('maria@correo.com')
expect(params.get('desarrollo')).toBe('SENDA')
expect(params.get('unidad')).toBe('U-9')
expect(params.get('oportunidadId')).toBe('opp-1')
})
it('omite los campos vacíos', () => {
const url = urlCotizadorParaOportunidad({ ...opBase, telefono: '', email: '', desarrollo: '', unidadCodigo: '' })
const params = new URLSearchParams(url.replace('/quote/?', ''))
expect(params.get('cliente')).toBe('María Pérez')
expect(params.get('oportunidadId')).toBe('opp-1')
expect(params.has('telefono')).toBe(false)
expect(params.has('email')).toBe(false)
expect(params.has('desarrollo')).toBe(false)
expect(params.has('unidad')).toBe(false)
})
})

View File

@@ -51,6 +51,30 @@ export interface ClienteMiDia {
estadoApartado: string | null
}
/** Oportunidad asignada al asesor (tab "Mis oportunidades") */
export interface OportunidadMiDia {
id: string
nombre: string
telefono: string
email: string
etapa: string
desarrollo: string
modeloInteres: string
unidadCodigo: string
updatedAt: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
/** Apartado asignado al vendedor (tab "Mis apartados") */
export interface ApartadoMiDia {
id: string
nombre: string
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
export interface ResumenMiDia {
generadoEn: string
sinMapeoMonday: boolean
@@ -59,7 +83,8 @@ export interface ResumenMiDia {
misClientes: ClienteMiDia[]
misActividadesHoy: ActividadMiDia[]
apartadosPorVencer: ApartadoPorVencer[]
misOportunidades: { porEtapa: Record<string, number> } // conteo de MIS oportunidades por etapa
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
misApartados: ApartadoMiDia[]
conteos: {
leadsNuevosSemana: number
actividadesAbiertas: number
@@ -94,6 +119,59 @@ export const MOTIVOS_NO_COMPRA = [
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
export const FASES_APARTADO = [
'Pago Apartado',
'Alta Enkontrol',
'Entrego Documentacion',
'Cita Clientes',
'Contrato Enkontrol',
'Firma Cliente (Venta)',
'Cancelacion Apartado',
] as const
export type FaseApartado = (typeof FASES_APARTADO)[number]
// Texto bonito de los botones (el valor que se manda al API es el label real)
export const ETIQUETA_FASE_APARTADO: Record<FaseApartado, string> = {
'Pago Apartado': 'Pago apartado',
'Alta Enkontrol': 'Alta Enkontrol',
'Entrego Documentacion': 'Entregó documentación',
'Cita Clientes': 'Cita cliente',
'Contrato Enkontrol': 'Contrato Enkontrol',
'Firma Cliente (Venta)': 'Firma cliente (venta)',
'Cancelacion Apartado': 'Cancelación',
}
// Texto bonito de los botones de oportunidad
export const ETIQUETA_FASE_OPORTUNIDAD: Record<FaseOportunidad, string> = {
'Agendo Cita': 'Agendó cita',
Afluencia: 'Afluencia',
Cotizador: 'Cotizador',
Aparto: 'Apartó',
'No Asistio': 'No asistió',
Abandono: 'Abandono',
}
/**
* URL del cotizador (/quote/) con los datos del cliente prellenados y el id de
* la oportunidad: al guardar, la cotización queda vinculada a ese cliente en
* Monday (el API actualiza ese item en vez de crear uno nuevo).
*/
export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
const params = new URLSearchParams()
params.set('cliente', op.nombre)
if (op.telefono !== '') params.set('telefono', op.telefono)
if (op.email !== '') params.set('email', op.email)
if (op.desarrollo !== '') params.set('desarrollo', op.desarrollo)
if (op.unidadCodigo !== '') params.set('unidad', op.unidadCodigo)
params.set('oportunidadId', op.id)
return `/quote/?${params.toString()}`
}
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
/** ¿El rol ve la sección de equipo? (misma regla que el API) */