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

@@ -2,6 +2,7 @@ import type { Request, Response } from 'express'
import { z } from 'zod'
import { config } from '../config/index.js'
import { mondayService, type MondayService } from '../services/monday.service.js'
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
export const MOTIVOS_NO_COMPRA = [
@@ -53,3 +54,61 @@ export async function perderOportunidadEndpoint(req: Request, res: Response): Pr
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
}
// ---------- Cambio de etapa desde Mi día ----------
/**
* Etapas del funnel que el asesor puede mover desde los botones de
* "Mis oportunidades" (labels REALES de la columna status 'Estado' del board
* Oportunidades — el frontend los manda tal cual).
*/
export const ETAPAS_OPORTUNIDAD_PERMITIDAS = [
'Agendo Cita',
'Afluencia',
'Cotizador',
'Aparto',
'No Asistio',
'Abandono',
] as const
const cambiarEtapaSchema = z.object({
etapa: z.enum(ETAPAS_OPORTUNIDAD_PERMITIDAS),
// En 'Agendo Cita' puede setear la columna date 'Fecha'
fecha: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe ser YYYY-MM-DD')
.optional(),
})
type MondayEtapa = Pick<
MondayService,
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
>
/**
* Cambia la etapa de la oportunidad (columna 'Estado'), setea la fecha de la
* cita si aplica y mueve el item al grupo que corresponde a la etapa nueva
* (idempotente; el webhook de change haría lo mismo, pero así la UI responde
* al instante).
*/
export async function cambiarEtapaOportunidad(
monday: MondayEtapa,
itemId: string,
etapa: (typeof ETAPAS_OPORTUNIDAD_PERMITIDAS)[number],
fecha?: string,
): Promise<void> {
const boardOportunidades = config.monday.boards.oportunidades
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Estado', etapa)
if (etapa === 'Agendo Cita' && fecha !== undefined) {
await monday.cambiarValorColumna(boardOportunidades, itemId, 'Fecha', fecha)
}
await moverAGrupoSegunEstado(monday, boardOportunidades, itemId)
}
/** POST /api/oportunidades/:itemId/etapa { etapa, fecha? } */
export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Response): Promise<void> {
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await cambiarEtapaOportunidad(mondayService, itemId, body.etapa, body.fecha)
res.json({ itemId, etapa: body.etapa, ...(body.fecha !== undefined ? { fecha: body.fecha } : {}) })
}