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:
68
api-gateway/src/controllers/apartados.controller.test.ts
Normal file
68
api-gateway/src/controllers/apartados.controller.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
cambiarEstadoApartado,
|
||||||
|
FASES_APARTADO_PERMITIDAS,
|
||||||
|
} from './apartados.controller.js'
|
||||||
|
|
||||||
|
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
|
||||||
|
function mondayMock(grupoActual = 'topics') {
|
||||||
|
return {
|
||||||
|
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||||
|
movimientos: [] as { itemId: string; grupo: string }[],
|
||||||
|
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||||
|
this.cambios.push({ boardId, itemId, columna, valor })
|
||||||
|
},
|
||||||
|
async columnaIdPorTitulo(_boardId: string, titulo: string) {
|
||||||
|
return titulo === 'Estado' ? 'status' : null
|
||||||
|
},
|
||||||
|
async getItemConGrupo(itemId: string) {
|
||||||
|
return {
|
||||||
|
id: itemId,
|
||||||
|
name: 'Cliente Demo',
|
||||||
|
groupId: grupoActual,
|
||||||
|
column_values: [
|
||||||
|
{ id: 'status', text: (this.cambios.find((c) => c.columna === 'Estado')?.valor as string) ?? '', value: '{}' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async moverItemAGrupo(itemId: string, grupo: string) {
|
||||||
|
this.movimientos.push({ itemId, grupo })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
|
||||||
|
it('cambia la fase (board 666 = MONDAY_BOARD_APARTADO de test)', async () => {
|
||||||
|
const monday = mondayMock()
|
||||||
|
await cambiarEstadoApartado(monday, 'apt-1', 'Alta Enkontrol')
|
||||||
|
expect(monday.cambios).toEqual([
|
||||||
|
{ boardId: '666', itemId: 'apt-1', columna: 'Estado', valor: 'Alta Enkontrol' },
|
||||||
|
])
|
||||||
|
// 'Alta Enkontrol' mapea al grupo topics: ya estaba ahí → no se mueve
|
||||||
|
expect(monday.movimientos).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Firma Cliente (Venta) mueve el item a "Clientes Activos"', async () => {
|
||||||
|
const monday = mondayMock()
|
||||||
|
await cambiarEstadoApartado(monday, 'apt-2', 'Firma Cliente (Venta)')
|
||||||
|
expect(monday.movimientos).toEqual([{ itemId: 'apt-2', grupo: 'group_mm6sx3rn' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Cancelacion Apartado mueve el item a "Historico"', async () => {
|
||||||
|
const monday = mondayMock()
|
||||||
|
await cambiarEstadoApartado(monday, 'apt-3', 'Cancelacion Apartado')
|
||||||
|
expect(monday.movimientos).toEqual([{ itemId: 'apt-3', grupo: 'group_mm6s2yyx' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('las fases permitidas son los labels reales del board Apartado', () => {
|
||||||
|
expect(FASES_APARTADO_PERMITIDAS).toEqual([
|
||||||
|
'Pago Apartado',
|
||||||
|
'Alta Enkontrol',
|
||||||
|
'Entrego Documentacion',
|
||||||
|
'Cita Clientes',
|
||||||
|
'Contrato Enkontrol',
|
||||||
|
'Firma Cliente (Venta)',
|
||||||
|
'Cancelacion Apartado',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
53
api-gateway/src/controllers/apartados.controller.ts
Normal file
53
api-gateway/src/controllers/apartados.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
|
||||||
|
* mover desde los botones de "Mis apartados" en Mi día. Son los labels REALES
|
||||||
|
* de la columna status 'Estado' del board (verificados en Monday) — el
|
||||||
|
* frontend los manda tal cual.
|
||||||
|
*/
|
||||||
|
export const FASES_APARTADO_PERMITIDAS = [
|
||||||
|
'Pago Apartado',
|
||||||
|
'Alta Enkontrol',
|
||||||
|
'Entrego Documentacion',
|
||||||
|
'Cita Clientes',
|
||||||
|
'Contrato Enkontrol',
|
||||||
|
'Firma Cliente (Venta)',
|
||||||
|
'Cancelacion Apartado',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const cambiarEstadoSchema = z.object({
|
||||||
|
estado: z.enum(FASES_APARTADO_PERMITIDAS),
|
||||||
|
})
|
||||||
|
|
||||||
|
type MondayApartados = Pick<
|
||||||
|
MondayService,
|
||||||
|
'cambiarValorColumna' | 'columnaIdPorTitulo' | 'getItemConGrupo' | 'moverItemAGrupo'
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambia la fase del apartado (columna 'Estado') y mueve el item al grupo que
|
||||||
|
* corresponde (idempotente; el webhook de change haría lo mismo, pero así la
|
||||||
|
* UI responde al instante).
|
||||||
|
*/
|
||||||
|
export async function cambiarEstadoApartado(
|
||||||
|
monday: MondayApartados,
|
||||||
|
itemId: string,
|
||||||
|
estado: (typeof FASES_APARTADO_PERMITIDAS)[number],
|
||||||
|
): Promise<void> {
|
||||||
|
const boardApartado = config.monday.boards.apartado
|
||||||
|
await monday.cambiarValorColumna(boardApartado, itemId, 'Estado', estado)
|
||||||
|
await moverAGrupoSegunEstado(monday, boardApartado, itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/apartados/:itemId/estado { estado } */
|
||||||
|
export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = cambiarEstadoSchema.parse(req.body)
|
||||||
|
const itemId = String(req.params['itemId'])
|
||||||
|
await cambiarEstadoApartado(mondayService, itemId, body.estado)
|
||||||
|
res.json({ itemId, estado: body.estado })
|
||||||
|
}
|
||||||
@@ -111,6 +111,24 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', ()
|
|||||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
|
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
|
||||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
|
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('con oportunidadId explícito (botón "Cotizar" de Mi día): actualiza ESE item y no consulta el funnel', async () => {
|
||||||
|
const { res, status, json } = resFake()
|
||||||
|
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(funnelMock.oportunidadPorTelefono).not.toHaveBeenCalled()
|
||||||
|
expect(mondayMock.crearOportunidad).not.toHaveBeenCalled()
|
||||||
|
expect(mondayMock.actualizarOportunidadCotizacion).toHaveBeenCalledOnce()
|
||||||
|
const [opId, datos] = mondayMock.actualizarOportunidadCotizacion.mock.calls[0] as [string, Record<string, unknown>]
|
||||||
|
expect(opId).toBe('opp-midia-1')
|
||||||
|
expect(datos).toMatchObject({ estadoLabel: 'Cotizador' })
|
||||||
|
// La cotización queda vinculada al item del cliente
|
||||||
|
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' })
|
||||||
|
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'cot-1' },
|
||||||
|
data: { mondayItemId: 'opp-midia-1' },
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
|
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const crearCotizacionSchema = z.object({
|
|||||||
clienteTelefono: z.string().min(7).optional(),
|
clienteTelefono: z.string().min(7).optional(),
|
||||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
||||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
||||||
|
// Item del tablero Oportunidades al que queda vinculada la cotización
|
||||||
|
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
|
||||||
|
oportunidadId: z.string().trim().min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const hoyISO = (): string => {
|
const hoyISO = (): string => {
|
||||||
@@ -88,9 +91,15 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
|||||||
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}),
|
||||||
}
|
}
|
||||||
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
|
// Si el teléfono corresponde a un lead del funnel, se ACTUALIZA su
|
||||||
// oportunidad (etapa 'Cotizador') en vez de crear una segunda.
|
// oportunidad (etapa 'Cotizador') en vez de crear una segunda. Con
|
||||||
|
// oportunidadId explícito (botón "Cotizar" de Mi día) se actualiza ESE
|
||||||
|
// item directamente y la cotización queda vinculada al cliente.
|
||||||
const opExistente =
|
const opExistente =
|
||||||
body.clienteTelefono !== undefined ? await funnelService.oportunidadPorTelefono(body.clienteTelefono) : null
|
body.oportunidadId !== undefined
|
||||||
|
? body.oportunidadId
|
||||||
|
: body.clienteTelefono !== undefined
|
||||||
|
? await funnelService.oportunidadPorTelefono(body.clienteTelefono)
|
||||||
|
: null
|
||||||
if (opExistente !== null) {
|
if (opExistente !== null) {
|
||||||
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
|
await mondayService.actualizarOportunidadCotizacion(opExistente, datosOportunidad)
|
||||||
mondayItemId = opExistente
|
mondayItemId = opExistente
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { perderOportunidad } from './oportunidades.controller.js'
|
import {
|
||||||
|
cambiarEtapaOportunidad,
|
||||||
|
ETAPAS_OPORTUNIDAD_PERMITIDAS,
|
||||||
|
perderOportunidad,
|
||||||
|
} from './oportunidades.controller.js'
|
||||||
|
|
||||||
/** Monday mockeado: registra los cambios de columna y los motivos creados */
|
/** Monday mockeado: registra los cambios de columna y los motivos creados */
|
||||||
function mondayMock() {
|
function mondayMock() {
|
||||||
@@ -50,3 +54,69 @@ describe('perderOportunidad (POST /api/oportunidades/:id/perder)', () => {
|
|||||||
expect(monday.motivos).toEqual([])
|
expect(monday.motivos).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Monday mockeado para cambio de etapa: registra cambios y movimientos de grupo */
|
||||||
|
function mondayEtapaMock(grupoActual = 'topics') {
|
||||||
|
return {
|
||||||
|
cambios: [] as { boardId: string; itemId: string; columna: string; valor: unknown }[],
|
||||||
|
movimientos: [] as { itemId: string; grupo: string }[],
|
||||||
|
async cambiarValorColumna(boardId: string, itemId: string, columna: string, valor: string | number | boolean) {
|
||||||
|
this.cambios.push({ boardId, itemId, columna, valor })
|
||||||
|
},
|
||||||
|
async columnaIdPorTitulo(_boardId: string, titulo: string) {
|
||||||
|
return titulo === 'Estado' ? 'status' : null
|
||||||
|
},
|
||||||
|
async getItemConGrupo(itemId: string) {
|
||||||
|
return {
|
||||||
|
id: itemId,
|
||||||
|
name: 'Cliente Demo',
|
||||||
|
groupId: grupoActual,
|
||||||
|
column_values: [{ id: 'status', text: this.cambios.find((c) => c.columna === 'Estado')?.valor as string ?? '', value: '{}' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async moverItemAGrupo(itemId: string, grupo: string) {
|
||||||
|
this.movimientos.push({ itemId, grupo })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cambiarEtapaOportunidad (POST /api/oportunidades/:id/etapa)', () => {
|
||||||
|
it('cambia la etapa y mueve al grupo destino (Abandono → Perdidos)', async () => {
|
||||||
|
const monday = mondayEtapaMock()
|
||||||
|
await cambiarEtapaOportunidad(monday, 'opp-1', 'Abandono')
|
||||||
|
expect(monday.cambios).toEqual([
|
||||||
|
{ boardId: '555', itemId: 'opp-1', columna: 'Estado', valor: 'Abandono' },
|
||||||
|
])
|
||||||
|
expect(monday.movimientos).toEqual([{ itemId: 'opp-1', grupo: 'group_mm6s4cgq' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Agendo Cita setea también la columna Fecha', async () => {
|
||||||
|
const monday = mondayEtapaMock()
|
||||||
|
await cambiarEtapaOportunidad(monday, 'opp-2', 'Agendo Cita', '2026-09-10')
|
||||||
|
expect(monday.cambios).toEqual([
|
||||||
|
{ boardId: '555', itemId: 'opp-2', columna: 'Estado', valor: 'Agendo Cita' },
|
||||||
|
{ boardId: '555', itemId: 'opp-2', columna: 'Fecha', valor: '2026-09-10' },
|
||||||
|
])
|
||||||
|
// 'Agendo Cita' mapea al grupo topics: ya estaba ahí → no se mueve
|
||||||
|
expect(monday.movimientos).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin fecha no toca la columna Fecha', async () => {
|
||||||
|
const monday = mondayEtapaMock()
|
||||||
|
await cambiarEtapaOportunidad(monday, 'opp-3', 'Cotizador')
|
||||||
|
expect(monday.cambios).toEqual([
|
||||||
|
{ boardId: '555', itemId: 'opp-3', columna: 'Estado', valor: 'Cotizador' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('las etapas permitidas son las fases del funnel definidas por el cliente', () => {
|
||||||
|
expect(ETAPAS_OPORTUNIDAD_PERMITIDAS).toEqual([
|
||||||
|
'Agendo Cita',
|
||||||
|
'Afluencia',
|
||||||
|
'Cotizador',
|
||||||
|
'Aparto',
|
||||||
|
'No Asistio',
|
||||||
|
'Abandono',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Request, Response } from 'express'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { mondayService, type MondayService } from '../services/monday.service.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) */
|
/** Categorías del dropdown "Motivo No Compra" (vocabulario CRM FRASA) */
|
||||||
export const MOTIVOS_NO_COMPRA = [
|
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)
|
const motivoItemId = await perderOportunidad(mondayService, mondayItemId, body.motivo, body.comentario)
|
||||||
res.json({ mondayItemId, etapa: 'Perdido', motivo: body.motivo, motivoItemId })
|
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 } : {}) })
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ import {
|
|||||||
obtenerPlan,
|
obtenerPlan,
|
||||||
registrarAbonoEndpoint,
|
registrarAbonoEndpoint,
|
||||||
} from '../controllers/planes.controller.js'
|
} from '../controllers/planes.controller.js'
|
||||||
import { perderOportunidadEndpoint } from '../controllers/oportunidades.controller.js'
|
import {
|
||||||
|
cambiarEtapaOportunidadEndpoint,
|
||||||
|
perderOportunidadEndpoint,
|
||||||
|
} from '../controllers/oportunidades.controller.js'
|
||||||
|
import { cambiarEstadoApartadoEndpoint } from '../controllers/apartados.controller.js'
|
||||||
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
|
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
|
||||||
import {
|
import {
|
||||||
cambiarEstadoLeadEndpoint,
|
cambiarEstadoLeadEndpoint,
|
||||||
@@ -134,6 +138,10 @@ apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
|
|||||||
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
|
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
|
||||||
|
|
||||||
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||||
|
// Cambio de etapa desde los botones de "Mis oportunidades" (Mi día)
|
||||||
|
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||||
|
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
||||||
|
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
||||||
|
|
||||||
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export const GRUPOS_POR_ESTADO: Record<string, { columna: string; destinos: Reco
|
|||||||
FORMALIZADO: 'group_mm6s40br',
|
FORMALIZADO: 'group_mm6s40br',
|
||||||
CANCELADO: 'group_mm6s2yyx',
|
CANCELADO: 'group_mm6s2yyx',
|
||||||
Cancelacion: 'group_mm6s2yyx',
|
Cancelacion: 'group_mm6s2yyx',
|
||||||
|
'Cancelacion Apartado': 'group_mm6s2yyx',
|
||||||
LIBERADO: 'group_mm6s2yyx',
|
LIBERADO: 'group_mm6s2yyx',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function apartado(parcial: Partial<ApartadoMonday> & { id: string }): ApartadoMo
|
|||||||
unidadCodigo: 'U-101',
|
unidadCodigo: 'U-101',
|
||||||
fechaLimite: '2026-09-02', // dentro de 7 días
|
fechaLimite: '2026-09-02', // dentro de 7 días
|
||||||
estado: 'Pago Apartado',
|
estado: 'Pago Apartado',
|
||||||
|
vendedorIds: [MI_ID],
|
||||||
...parcial,
|
...parcial,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +198,71 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
||||||
expect(r.misOportunidades).toEqual({ porEtapa: { Afluencia: 2, Ganado: 1 } })
|
expect(r.misOportunidades.porEtapa).toEqual({ Afluencia: 2, Ganado: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('misOportunidades.items: lista de trabajo sin terminales, mías, por updatedAt desc, con perfil y último cambio', async () => {
|
||||||
|
const monday = mondayMock({
|
||||||
|
oportunidades: [
|
||||||
|
oportunidad({ id: 'o1', etapa: 'Afluencia', updatedAt: '2026-08-27T10:00:00Z', desarrollo: 'SENDA', modeloInteres: 'SOHO' }),
|
||||||
|
oportunidad({ id: 'o2', etapa: 'Cotizador', updatedAt: '2026-08-28T08:00:00Z', telefono: '4771234567', email: 'a@b.c', unidadCodigo: 'U-9' }),
|
||||||
|
oportunidad({ id: 'o3', etapa: 'Ganado' }), // terminal: fuera de la lista (sí cuenta en porEtapa)
|
||||||
|
oportunidad({ id: 'o4', etapa: 'Abandono' }), // terminal: fuera
|
||||||
|
oportunidad({ id: 'o5', etapa: 'Afluencia', asesorIds: [OTRO_ID] }), // de otro: fuera
|
||||||
|
],
|
||||||
|
ultimosCambios: {
|
||||||
|
'555': new Map([['o2', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: MI_ID, evento: 'update_column_value' }]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const nombres = new Map([[MI_ID, 'Martin Zavala']])
|
||||||
|
const r = await resumenMiDia(monday, vendedor, nombres, [], [], AHORA)
|
||||||
|
expect(r.misOportunidades.items.map((o) => o.id)).toEqual(['o2', 'o1'])
|
||||||
|
const o2 = r.misOportunidades.items[0]
|
||||||
|
expect(o2?.etapa).toBe('Cotizador')
|
||||||
|
expect(o2?.telefono).toBe('4771234567')
|
||||||
|
expect(o2?.email).toBe('a@b.c')
|
||||||
|
expect(o2?.unidadCodigo).toBe('U-9')
|
||||||
|
expect(o2?.ultimoCambio).toEqual({
|
||||||
|
fechaISO: '2026-08-28T09:00:00.000Z',
|
||||||
|
usuario: 'Martin Zavala',
|
||||||
|
detalle: 'Actualización',
|
||||||
|
})
|
||||||
|
const o1 = r.misOportunidades.items[1]
|
||||||
|
expect(o1?.desarrollo).toBe('SENDA')
|
||||||
|
expect(o1?.modeloInteres).toBe('SOHO')
|
||||||
|
expect(o1?.ultimoCambio).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('misApartados: Vendedor = yo, sin estados finales, por fecha límite asc (sin fecha al final), con último cambio', async () => {
|
||||||
|
const monday = mondayMock({
|
||||||
|
apartados: [
|
||||||
|
apartado({ id: 'p1', fechaLimite: '2026-09-05' }),
|
||||||
|
apartado({ id: 'p2', fechaLimite: '2026-09-01', estado: 'Cita Clientes' }),
|
||||||
|
apartado({ id: 'p3', fechaLimite: '', estado: 'Alta Enkontrol' }),
|
||||||
|
apartado({ id: 'p4', estado: 'Firma Cliente (Venta)' }), // final: fuera
|
||||||
|
apartado({ id: 'p5', estado: 'Cancelacion Apartado' }), // final: fuera
|
||||||
|
apartado({ id: 'p6', vendedorIds: [OTRO_ID] }), // de otro: fuera
|
||||||
|
],
|
||||||
|
ultimosCambios: {
|
||||||
|
'666': new Map([['p1', { fechaISO: '2026-08-28T09:00:00.000Z', usuarioId: OTRO_ID, evento: 'create_update' }]]),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], [], AHORA)
|
||||||
|
expect(r.misApartados.map((a) => a.id)).toEqual(['p2', 'p1', 'p3'])
|
||||||
|
expect(r.misApartados[0]?.estado).toBe('Cita Clientes')
|
||||||
|
expect(r.misApartados[1]?.ultimoCambio).toEqual({
|
||||||
|
fechaISO: '2026-08-28T09:00:00.000Z',
|
||||||
|
usuario: null, // OTRO_ID no está en el mapa de nombres
|
||||||
|
detalle: 'Nota agregada',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('esEstadoFinalApartado: venta y cancelación cierran el pipeline', () => {
|
||||||
|
expect(esEstadoFinalApartado('Firma Cliente (Venta)')).toBe(true)
|
||||||
|
expect(esEstadoFinalApartado('Cancelacion Apartado')).toBe(true)
|
||||||
|
expect(esEstadoFinalApartado('Escriturado')).toBe(true)
|
||||||
|
expect(esEstadoFinalApartado('Pago Apartado')).toBe(false)
|
||||||
|
expect(esEstadoFinalApartado('Contrato Enkontrol')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
|
it('actividades de hoy: solo abiertas con fecha <= mañana y mías', async () => {
|
||||||
@@ -244,7 +309,8 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
|
const r = await resumenMiDia(monday, sinMapeo, new Map(), [], [], AHORA)
|
||||||
expect(r.sinMapeoMonday).toBe(true)
|
expect(r.sinMapeoMonday).toBe(true)
|
||||||
expect(r.misLeads).toEqual([])
|
expect(r.misLeads).toEqual([])
|
||||||
expect(r.misOportunidades).toEqual({ porEtapa: {} })
|
expect(r.misOportunidades).toEqual({ porEtapa: {}, items: [] })
|
||||||
|
expect(r.misApartados).toEqual([])
|
||||||
expect(r.misActividadesHoy).toEqual([])
|
expect(r.misActividadesHoy).toEqual([])
|
||||||
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
|
expect(r.conteos).toEqual({ leadsNuevosSemana: 0, actividadesAbiertas: 0 })
|
||||||
// Los apartados por vencer son del pipeline completo: sí se muestran
|
// Los apartados por vencer son del pipeline completo: sí se muestran
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
|
|||||||
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
|
export const ETAPAS_TERMINALES_OPORTUNIDAD = ['GANADO', 'PERDIDO', 'ABANDONO']
|
||||||
|
|
||||||
// Estados del board Apartado que cierran el pipeline Enkontrol
|
// Estados del board Apartado que cierran el pipeline Enkontrol
|
||||||
export const ESTADOS_FINALES_APARTADO = ['ESCRITURADO', 'CANCELACION', 'CANCELADO', 'LIBERADO', 'FORMALIZADO']
|
export const ESTADOS_FINALES_APARTADO = [
|
||||||
|
'ESCRITURADO',
|
||||||
|
'CANCELACION',
|
||||||
|
'CANCELACION APARTADO',
|
||||||
|
'CANCELADO',
|
||||||
|
'LIBERADO',
|
||||||
|
'FORMALIZADO',
|
||||||
|
'FIRMA CLIENTE (VENTA)', // la venta se concreta: pasa a Escrituración
|
||||||
|
]
|
||||||
|
|
||||||
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||||
|
|
||||||
@@ -81,6 +89,32 @@ export interface ClienteMiDia {
|
|||||||
estadoApartado: string | null
|
estadoApartado: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Oportunidad asignada al asesor (tab "Mis oportunidades" de Mi día) */
|
||||||
|
export interface OportunidadMiDia {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
email: string
|
||||||
|
etapa: string // label de la columna status 'Estado'
|
||||||
|
desarrollo: string
|
||||||
|
modeloInteres: string
|
||||||
|
unidadCodigo: string
|
||||||
|
updatedAt: string
|
||||||
|
/** Último evento del activity log del board; null si el item no tiene eventos */
|
||||||
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
|
||||||
|
export interface ApartadoMiDia {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
unidadCodigo: string
|
||||||
|
estado: string
|
||||||
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
|
/** Último evento del activity log del board; null si el item no tiene eventos */
|
||||||
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
sinMapeoMonday: boolean
|
sinMapeoMonday: boolean
|
||||||
@@ -89,8 +123,10 @@ export interface ResumenMiDia {
|
|||||||
misClientes: ClienteMiDia[]
|
misClientes: ClienteMiDia[]
|
||||||
misActividadesHoy: ActividadMiDia[]
|
misActividadesHoy: ActividadMiDia[]
|
||||||
apartadosPorVencer: ApartadoPorVencer[]
|
apartadosPorVencer: ApartadoPorVencer[]
|
||||||
/** Conteo de MIS oportunidades por etapa (columna Estado del board) */
|
/** MIS oportunidades: conteo por etapa + lista de trabajo (etapas no terminales) */
|
||||||
misOportunidades: { porEtapa: Record<string, number> }
|
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] }
|
||||||
|
/** MIS apartados (Vendedor = yo), en estados no finales del pipeline Enkontrol */
|
||||||
|
misApartados: ApartadoMiDia[]
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
leadsNuevosSemana: number // míos, últimos 7 días naturales
|
||||||
actividadesAbiertas: number // mías, Estado Abierto
|
actividadesAbiertas: number // mías, Estado Abierto
|
||||||
@@ -223,17 +259,41 @@ export async function resumenMiDia(
|
|||||||
b.updatedAt.localeCompare(a.updatedAt),
|
b.updatedAt.localeCompare(a.updatedAt),
|
||||||
)
|
)
|
||||||
|
|
||||||
// "Último cambio" por item: UNA llamada al activity log por tablero (máx 2),
|
// "Último cambio" por item: UNA llamada al activity log por tablero (máx 3),
|
||||||
// agrupando los ids por fuente. El usuario se resuelve con el mapa
|
// agrupando los ids por fuente. El usuario se resuelve con el mapa
|
||||||
// mondayUserId → nombre que pasa el controller (ecosistema + workspace).
|
// mondayUserId → nombre que pasa el controller (ecosistema + workspace).
|
||||||
const [cambiosLeads, cambiosOportunidades] = await Promise.all([
|
const itemsOportunidades: OportunidadMiDia[] = mios
|
||||||
|
.filter((o) => !esEtapaTerminal(o.etapa))
|
||||||
|
.map((o) => ({
|
||||||
|
id: o.id,
|
||||||
|
nombre: o.nombre,
|
||||||
|
telefono: o.telefono,
|
||||||
|
email: o.email,
|
||||||
|
etapa: o.etapa,
|
||||||
|
desarrollo: o.desarrollo ?? '',
|
||||||
|
modeloInteres: o.modeloInteres ?? '',
|
||||||
|
unidadCodigo: o.unidadCodigo,
|
||||||
|
updatedAt: o.updatedAt,
|
||||||
|
ultimoCambio: null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
|
|
||||||
|
const miosApartados = sinMapeo
|
||||||
|
? []
|
||||||
|
: apartados.filter((a) => a.vendedorIds.includes(miId) && !esEstadoFinalApartado(a.estado))
|
||||||
|
|
||||||
|
const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([
|
||||||
monday.getUltimosCambios(
|
monday.getUltimosCambios(
|
||||||
config.monday.boards.leads,
|
config.monday.boards.leads,
|
||||||
misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id),
|
misLeads.filter((l) => l.fuente === 'lead').map((l) => l.id),
|
||||||
),
|
),
|
||||||
monday.getUltimosCambios(
|
monday.getUltimosCambios(
|
||||||
config.monday.boards.oportunidades,
|
config.monday.boards.oportunidades,
|
||||||
misLeads.filter((l) => l.fuente === 'oportunidad').map((l) => l.id),
|
itemsOportunidades.map((o) => o.id),
|
||||||
|
),
|
||||||
|
monday.getUltimosCambios(
|
||||||
|
config.monday.boards.apartado,
|
||||||
|
miosApartados.map((a) => a.id),
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades }
|
const cambiosPorFuente = { lead: cambiosLeads, oportunidad: cambiosOportunidades }
|
||||||
@@ -247,6 +307,38 @@ export async function resumenMiDia(
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
for (const op of itemsOportunidades) {
|
||||||
|
const cambio = cambiosOportunidades.get(op.id)
|
||||||
|
op.ultimoCambio = cambio
|
||||||
|
? {
|
||||||
|
fechaISO: cambio.fechaISO,
|
||||||
|
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
|
||||||
|
detalle: detalleEventoUltimoCambio(cambio.evento),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol,
|
||||||
|
// por fecha límite ascendente (sin fecha al final)
|
||||||
|
const misApartados: ApartadoMiDia[] = miosApartados
|
||||||
|
.map((a) => {
|
||||||
|
const cambio = cambiosApartados.get(a.id)
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
nombre: a.nombre,
|
||||||
|
unidadCodigo: a.unidadCodigo,
|
||||||
|
estado: a.estado,
|
||||||
|
fechaLimite: a.fechaLimite,
|
||||||
|
ultimoCambio: cambio
|
||||||
|
? {
|
||||||
|
fechaISO: cambio.fechaISO,
|
||||||
|
usuario: cambio.usuarioId !== null ? (nombresPorMondayId.get(cambio.usuarioId) ?? null) : null,
|
||||||
|
detalle: detalleEventoUltimoCambio(cambio.evento),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.fechaLimite || '9999-12-31').localeCompare(b.fechaLimite || '9999-12-31'))
|
||||||
|
|
||||||
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
|
// Conteo de MIS oportunidades por etapa (incluye terminales: es el resumen
|
||||||
// del pipeline personal, no la lista de trabajo activo)
|
// del pipeline personal, no la lista de trabajo activo)
|
||||||
@@ -336,7 +428,8 @@ export async function resumenMiDia(
|
|||||||
misClientes,
|
misClientes,
|
||||||
misActividadesHoy,
|
misActividadesHoy,
|
||||||
apartadosPorVencer,
|
apartadosPorVencer,
|
||||||
misOportunidades: { porEtapa },
|
misOportunidades: { porEtapa, items: itemsOportunidades },
|
||||||
|
misApartados,
|
||||||
conteos,
|
conteos,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export interface ApartadoMonday {
|
|||||||
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
unidadCodigo: string // columna text 'Unidad' ('' si no tiene)
|
||||||
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' de 'Fecha límite' ('' si no tiene)
|
||||||
estado: string // label de la columna status 'Estado'
|
estado: string // label de la columna status 'Estado'
|
||||||
|
vendedorIds: number[] // user IDs asignados en la columna people 'Vendedor'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
|
/** Contacto del tablero Contactos (módulo Contactos de la landing) */
|
||||||
@@ -1063,13 +1064,14 @@ export class MondayService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Todos los apartados del tablero Apartado, con unidad, fecha límite y estado */
|
/** Todos los apartados del tablero Apartado, con unidad, fecha límite, estado y vendedor */
|
||||||
async getApartados(): Promise<ApartadoMonday[]> {
|
async getApartados(): Promise<ApartadoMonday[]> {
|
||||||
const boardId = config.monday.boards.apartado
|
const boardId = config.monday.boards.apartado
|
||||||
const cols = await this.columnasDe(boardId)
|
const cols = await this.columnasDe(boardId)
|
||||||
const colUnidad = cols.get('Unidad')
|
const colUnidad = cols.get('Unidad')
|
||||||
const colFechaLimite = cols.get('Fecha límite')
|
const colFechaLimite = cols.get('Fecha límite')
|
||||||
const colEstado = cols.get('Estado')
|
const colEstado = cols.get('Estado')
|
||||||
|
const colVendedor = cols.get('Vendedor')
|
||||||
|
|
||||||
const items: ItemMonday[] = []
|
const items: ItemMonday[] = []
|
||||||
let cursor: string | null = null
|
let cursor: string | null = null
|
||||||
@@ -1116,6 +1118,7 @@ export class MondayService {
|
|||||||
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
unidadCodigo: colUnidad ? (porId.get(colUnidad.id)?.text ?? '') : '',
|
||||||
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
|
fechaLimite: colFechaLimite ? (porId.get(colFechaLimite.id)?.text ?? '') : '',
|
||||||
estado,
|
estado,
|
||||||
|
vendedorIds: colVendedor ? this.peopleIds(porId.get(colVendedor.id)?.value) : [],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export default function CotizadorPage() {
|
|||||||
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
|
const [modalClienteAbierto, setModalClienteAbierto] = 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
|
||||||
|
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
|
||||||
|
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
|
||||||
|
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let vivo = true
|
let vivo = true
|
||||||
@@ -113,6 +117,41 @@ export default function CotizadorPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const nombreCliente = watch('nombre') ?? ''
|
const nombreCliente = watch('nombre') ?? ''
|
||||||
|
|
||||||
|
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
|
||||||
|
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
|
||||||
|
useEffect(() => {
|
||||||
|
const qs = new URLSearchParams(window.location.search)
|
||||||
|
const cliente = qs.get('cliente')
|
||||||
|
const telefono = qs.get('telefono')
|
||||||
|
const email = qs.get('email')
|
||||||
|
const desarrolloParam = qs.get('desarrollo')
|
||||||
|
const unidadParam = qs.get('unidad')
|
||||||
|
const opId = qs.get('oportunidadId')
|
||||||
|
if (cliente) setValue('nombre', cliente, { shouldValidate: true })
|
||||||
|
if (telefono) setValue('telefono', telefono, { shouldValidate: true })
|
||||||
|
if (email) setValue('email', email, { shouldValidate: true })
|
||||||
|
if (desarrolloParam && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
|
||||||
|
if (unidadParam) setUnidadPendiente(unidadParam)
|
||||||
|
if (opId) setOportunidadId(opId)
|
||||||
|
// Solo al montar: es el punto de entrada desde Mi día
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Preselecciona la unidad cuando el inventario terminó de cargar
|
||||||
|
useEffect(() => {
|
||||||
|
if (unidadPendiente === null || cargando) return
|
||||||
|
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
|
||||||
|
if (encontrada) {
|
||||||
|
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
|
||||||
|
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
|
||||||
|
setUnidad(encontrada)
|
||||||
|
setAviso(`Unidad ${encontrada.codigo} preseleccionada desde Mi día.`)
|
||||||
|
}
|
||||||
|
setUnidadPendiente(null)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [unidades, cargando, unidadPendiente])
|
||||||
|
|
||||||
// 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))]
|
||||||
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando
|
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando
|
||||||
@@ -130,6 +169,7 @@ export default function CotizadorPage() {
|
|||||||
acabados,
|
acabados,
|
||||||
apartado: apartadoEfectivo,
|
apartado: apartadoEfectivo,
|
||||||
descuento: propuestaActiva.descuento,
|
descuento: propuestaActiva.descuento,
|
||||||
|
...(oportunidadId !== null ? { oportunidadId } : {}),
|
||||||
})
|
})
|
||||||
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
||||||
setUltimaGuardada(conFolio)
|
setUltimaGuardada(conFolio)
|
||||||
@@ -250,6 +290,7 @@ export default function CotizadorPage() {
|
|||||||
nueva()
|
nueva()
|
||||||
reset({ nombre: '', email: '', telefono: '' })
|
reset({ nombre: '', email: '', telefono: '' })
|
||||||
setUltimaGuardada(null)
|
setUltimaGuardada(null)
|
||||||
|
setOportunidadId(null) // la nueva cotización ya no va ligada a la oportunidad anterior
|
||||||
setAviso(null)
|
setAviso(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +306,15 @@ export default function CotizadorPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{oportunidadId !== null && nombreCliente.trim() !== '' && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm font-medium text-blue-800"
|
||||||
|
>
|
||||||
|
Cotizando para {nombreCliente} — al guardar, la cotización queda vinculada a su oportunidad en Monday.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid items-start gap-6 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
|
<div className="grid items-start gap-6 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
|
||||||
{/* Panel de configuración */}
|
{/* Panel de configuración */}
|
||||||
<aside className="space-y-6 lg:sticky lg:top-24">
|
<aside className="space-y-6 lg:sticky lg:top-24">
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export interface GuardarCotizacionPayload {
|
|||||||
acabados: boolean
|
acabados: boolean
|
||||||
apartado?: number
|
apartado?: number
|
||||||
descuento?: number
|
descuento?: number
|
||||||
|
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
|
||||||
|
oportunidadId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** URL pública de una cotización (destino del QR del PDF) */
|
/** URL pública de una cotización (destino del QR del PDF) */
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
esRolConEquipo,
|
esRolConEquipo,
|
||||||
formatoFechaCorta,
|
formatoFechaCorta,
|
||||||
hoyLocal,
|
hoyLocal,
|
||||||
|
urlCotizadorParaOportunidad,
|
||||||
DESARROLLOS_LEAD,
|
DESARROLLOS_LEAD,
|
||||||
MOTIVOS_NO_COMPRA,
|
MOTIVOS_NO_COMPRA,
|
||||||
ORIGENES_LEAD,
|
ORIGENES_LEAD,
|
||||||
textoDiasRestantes,
|
textoDiasRestantes,
|
||||||
|
type OportunidadMiDia,
|
||||||
} from './mi-dia'
|
} from './mi-dia'
|
||||||
|
|
||||||
describe('catálogos', () => {
|
describe('catálogos', () => {
|
||||||
@@ -67,3 +69,41 @@ describe('hoyLocal', () => {
|
|||||||
expect(hoyLocal()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -51,6 +51,30 @@ export interface ClienteMiDia {
|
|||||||
estadoApartado: string | null
|
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 {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
sinMapeoMonday: boolean
|
sinMapeoMonday: boolean
|
||||||
@@ -59,7 +83,8 @@ export interface ResumenMiDia {
|
|||||||
misClientes: ClienteMiDia[]
|
misClientes: ClienteMiDia[]
|
||||||
misActividadesHoy: ActividadMiDia[]
|
misActividadesHoy: ActividadMiDia[]
|
||||||
apartadosPorVencer: ApartadoPorVencer[]
|
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: {
|
conteos: {
|
||||||
leadsNuevosSemana: number
|
leadsNuevosSemana: number
|
||||||
actividadesAbiertas: number
|
actividadesAbiertas: number
|
||||||
@@ -94,6 +119,59 @@ export const MOTIVOS_NO_COMPRA = [
|
|||||||
|
|
||||||
export type MotivoNoCompra = (typeof MOTIVOS_NO_COMPRA)[number]
|
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']
|
const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin']
|
||||||
|
|
||||||
/** ¿El rol ve la sección de equipo? (misma regla que el API) */
|
/** ¿El rol ve la sección de equipo? (misma regla que el API) */
|
||||||
|
|||||||
@@ -7,14 +7,28 @@ import {
|
|||||||
formatoFechaCorta,
|
formatoFechaCorta,
|
||||||
hoyLocal,
|
hoyLocal,
|
||||||
DESARROLLOS_LEAD,
|
DESARROLLOS_LEAD,
|
||||||
|
ETIQUETA_FASE_APARTADO,
|
||||||
|
ETIQUETA_FASE_OPORTUNIDAD,
|
||||||
|
FASES_APARTADO,
|
||||||
MOTIVOS_NO_COMPRA,
|
MOTIVOS_NO_COMPRA,
|
||||||
ORIGENES_LEAD,
|
ORIGENES_LEAD,
|
||||||
textoDiasRestantes,
|
textoDiasRestantes,
|
||||||
|
urlCotizadorParaOportunidad,
|
||||||
|
type ApartadoMiDia,
|
||||||
|
type FaseApartado,
|
||||||
type LeadMiDia,
|
type LeadMiDia,
|
||||||
type MotivoNoCompra,
|
type MotivoNoCompra,
|
||||||
|
type OportunidadMiDia,
|
||||||
type ResumenMiDia,
|
type ResumenMiDia,
|
||||||
} from '../lib/mi-dia'
|
} from '../lib/mi-dia'
|
||||||
import { cambiarEstadoLead, crearLead, descartarLead, fetchResumenMiDia } from '../services/mi-dia'
|
import {
|
||||||
|
cambiarEstadoApartado,
|
||||||
|
cambiarEstadoLead,
|
||||||
|
cambiarEtapaOportunidad,
|
||||||
|
crearLead,
|
||||||
|
descartarLead,
|
||||||
|
fetchResumenMiDia,
|
||||||
|
} from '../services/mi-dia'
|
||||||
import { getSesion } from '../services/auth'
|
import { getSesion } from '../services/auth'
|
||||||
|
|
||||||
function TituloSeccion({ children }: { children: string }) {
|
function TituloSeccion({ children }: { children: string }) {
|
||||||
@@ -44,7 +58,7 @@ function ModalDescartar({
|
|||||||
onConfirmar,
|
onConfirmar,
|
||||||
enviando,
|
enviando,
|
||||||
}: {
|
}: {
|
||||||
lead: LeadMiDia
|
lead: { id: string; nombre: string }
|
||||||
onCancelar: () => void
|
onCancelar: () => void
|
||||||
onConfirmar: (motivo: MotivoNoCompra, comentario: string) => void
|
onConfirmar: (motivo: MotivoNoCompra, comentario: string) => void
|
||||||
enviando: boolean
|
enviando: boolean
|
||||||
@@ -115,6 +129,201 @@ function ModalDescartar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
|
||||||
|
function FilaOportunidad({
|
||||||
|
op,
|
||||||
|
onEtapa,
|
||||||
|
onCotizar,
|
||||||
|
onDescartar,
|
||||||
|
enviando,
|
||||||
|
}: {
|
||||||
|
op: OportunidadMiDia
|
||||||
|
onEtapa: (etapa: string, fecha?: string) => void
|
||||||
|
onCotizar: () => void
|
||||||
|
onDescartar: () => void
|
||||||
|
enviando: boolean
|
||||||
|
}) {
|
||||||
|
const [conCita, setConCita] = useState(false)
|
||||||
|
const [fechaCita, setFechaCita] = useState('')
|
||||||
|
const fases = ['Agendo Cita', 'Afluencia', 'Aparto', 'No Asistio', 'Abandono'] as const
|
||||||
|
return (
|
||||||
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{op.nombre}</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B]">
|
||||||
|
{op.telefono || 'Sin teléfono'}
|
||||||
|
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
|
||||||
|
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
|
||||||
|
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
|
||||||
|
</p>
|
||||||
|
{op.ultimoCambio && (
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
||||||
|
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||||
|
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{op.etapa || 'Sin etapa'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={enviando}
|
||||||
|
onClick={onCotizar}
|
||||||
|
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cotizar →
|
||||||
|
</button>
|
||||||
|
{fases.map((f) =>
|
||||||
|
f === 'Agendo Cita' ? (
|
||||||
|
<span key={f} className="contents">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={enviando}
|
||||||
|
onClick={() => (conCita ? onEtapa(f, fechaCita || undefined) : setConCita(true))}
|
||||||
|
className={CLASE_BOTON_ACCION}
|
||||||
|
>
|
||||||
|
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||||
|
</button>
|
||||||
|
{conCita && (
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
aria-label="Fecha de la cita"
|
||||||
|
value={fechaCita}
|
||||||
|
onChange={(e) => setFechaCita(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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
disabled={enviando}
|
||||||
|
onClick={() => onEtapa(f)}
|
||||||
|
className={CLASE_BOTON_ACCION}
|
||||||
|
>
|
||||||
|
{ETIQUETA_FASE_OPORTUNIDAD[f]}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={enviando}
|
||||||
|
onClick={onDescartar}
|
||||||
|
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fila de apartado con botones de fase del pipeline Enkontrol */
|
||||||
|
function FilaApartado({
|
||||||
|
apartado,
|
||||||
|
onFase,
|
||||||
|
enviando,
|
||||||
|
}: {
|
||||||
|
apartado: ApartadoMiDia
|
||||||
|
onFase: (fase: FaseApartado) => void
|
||||||
|
enviando: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{apartado.nombre}
|
||||||
|
{apartado.unidadCodigo ? (
|
||||||
|
<span className="ml-2 text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B]">
|
||||||
|
{apartado.fechaLimite !== '' ? `Fecha límite: ${formatoFechaCorta(apartado.fechaLimite)}` : 'Sin fecha límite'}
|
||||||
|
</p>
|
||||||
|
{apartado.ultimoCambio && (
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF]">
|
||||||
|
Último cambio: {apartado.ultimoCambio.detalle} · {apartado.ultimoCambio.usuario ?? '—'} ·{' '}
|
||||||
|
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{apartado.estado || 'Sin estado'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{FASES_APARTADO.map((fase) => (
|
||||||
|
<button
|
||||||
|
key={fase}
|
||||||
|
type="button"
|
||||||
|
disabled={enviando}
|
||||||
|
onClick={() => onFase(fase)}
|
||||||
|
className={
|
||||||
|
fase === 'Cancelacion Apartado'
|
||||||
|
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50'
|
||||||
|
: fase === apartado.estado
|
||||||
|
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
|
||||||
|
: CLASE_BOTON_ACCION
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ETIQUETA_FASE_APARTADO[fase]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados */
|
||||||
|
type TabCrm = 'leads' | 'oportunidades' | 'apartados'
|
||||||
|
|
||||||
|
function TabsCrm({
|
||||||
|
activa,
|
||||||
|
conteos,
|
||||||
|
onCambiar,
|
||||||
|
}: {
|
||||||
|
activa: TabCrm
|
||||||
|
conteos: Record<TabCrm, number>
|
||||||
|
onCambiar: (tab: TabCrm) => void
|
||||||
|
}) {
|
||||||
|
const tabs: { id: TabCrm; titulo: string }[] = [
|
||||||
|
{ id: 'leads', titulo: 'Mis leads' },
|
||||||
|
{ id: 'oportunidades', titulo: 'Mis oportunidades' },
|
||||||
|
{ id: 'apartados', titulo: 'Mis apartados' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-white p-1 shadow-sm" role="tablist">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const activo = tab.id === activa
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activo}
|
||||||
|
onClick={() => onCambiar(tab.id)}
|
||||||
|
className={`rounded-lg px-4 py-2 text-xs font-semibold uppercase tracking-wider transition-colors duration-100 ${
|
||||||
|
activo ? 'bg-accent text-white' : 'text-[#64748B] hover:bg-[#F9FAFB] hover:text-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.titulo}
|
||||||
|
<span className={`ml-1.5 font-normal ${activo ? 'text-blue-100' : 'text-[#94A3B8]'}`}>
|
||||||
|
{conteos[tab.id]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Fila de lead con acciones rápidas (contactado / intento / cita / descartar) */
|
/** Fila de lead con acciones rápidas (contactado / intento / cita / descartar) */
|
||||||
function FilaLead({
|
function FilaLead({
|
||||||
lead,
|
lead,
|
||||||
@@ -206,7 +415,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
const [cargando, setCargando] = useState(true)
|
const [cargando, setCargando] = useState(true)
|
||||||
const [enviando, setEnviando] = useState(false)
|
const [enviando, setEnviando] = useState(false)
|
||||||
const [aviso, setAviso] = useState<string | null>(null)
|
const [aviso, setAviso] = useState<string | null>(null)
|
||||||
const [descartando, setDescartando] = useState<LeadMiDia | null>(null)
|
const [descartando, setDescartando] = useState<{
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
fuente: 'lead' | 'oportunidad'
|
||||||
|
} | null>(null)
|
||||||
|
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
|
||||||
|
|
||||||
// Captura rápida
|
// Captura rápida
|
||||||
const [capNombre, setCapNombre] = useState('')
|
const [capNombre, setCapNombre] = useState('')
|
||||||
@@ -248,6 +462,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Botón "Cotizar": pasa la oportunidad a etapa Cotizador y abre el cotizador
|
||||||
|
* con los datos del cliente prellenados y la oportunidad vinculada (al
|
||||||
|
* guardar, la cotización actualiza ESE item en Monday). Si el cambio de etapa
|
||||||
|
* falla, el cotizador se abre igual: el guardado reintenta la actualización.
|
||||||
|
*/
|
||||||
|
async function cotizarOportunidad(op: OportunidadMiDia) {
|
||||||
|
setEnviando(true)
|
||||||
|
setAviso(null)
|
||||||
|
try {
|
||||||
|
await cambiarEtapaOportunidad(op.id, 'Cotizador')
|
||||||
|
} catch {
|
||||||
|
setAviso('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
|
||||||
|
}
|
||||||
|
window.location.href = urlCotizadorParaOportunidad(op)
|
||||||
|
}
|
||||||
|
|
||||||
async function enviarCaptura(e: FormEvent) {
|
async function enviarCaptura(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setCapEnviando(true)
|
setCapEnviando(true)
|
||||||
@@ -382,23 +613,71 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
|
<div className="mt-6 flex flex-col gap-6 lg:grid lg:grid-cols-3 lg:gap-6">
|
||||||
<div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
|
<div className="order-2 flex flex-col gap-6 lg:order-1 lg:col-span-2">
|
||||||
<section aria-label="Mis leads">
|
<section aria-label="CRM personal">
|
||||||
<TituloSeccion>Mis leads</TituloSeccion>
|
<TabsCrm
|
||||||
{resumen.misLeads.length === 0 ? (
|
activa={tabCrm}
|
||||||
<EstadoVacio mensaje="Sin leads asignados" />
|
conteos={{
|
||||||
) : (
|
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
||||||
<ul className="space-y-2">
|
oportunidades: resumen.misOportunidades.items.length,
|
||||||
{resumen.misLeads.map((lead) => (
|
apartados: resumen.misApartados.length,
|
||||||
<FilaLead
|
}}
|
||||||
key={`${lead.fuente}-${lead.id}`}
|
onCambiar={setTabCrm}
|
||||||
lead={lead}
|
/>
|
||||||
enviando={enviando}
|
|
||||||
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
{tabCrm === 'leads' &&
|
||||||
onDescartar={() => setDescartando(lead)}
|
(resumen.misLeads.filter((l) => l.fuente === 'lead').length === 0 ? (
|
||||||
/>
|
<EstadoVacio mensaje="Sin leads asignados" />
|
||||||
))}
|
) : (
|
||||||
</ul>
|
<ul className="space-y-2">
|
||||||
)}
|
{resumen.misLeads
|
||||||
|
.filter((l) => l.fuente === 'lead')
|
||||||
|
.map((lead) => (
|
||||||
|
<FilaLead
|
||||||
|
key={`${lead.fuente}-${lead.id}`}
|
||||||
|
lead={lead}
|
||||||
|
enviando={enviando}
|
||||||
|
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
|
||||||
|
onDescartar={() => setDescartando(lead)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tabCrm === 'oportunidades' &&
|
||||||
|
(resumen.misOportunidades.items.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Sin oportunidades asignadas" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{resumen.misOportunidades.items.map((op) => (
|
||||||
|
<FilaOportunidad
|
||||||
|
key={op.id}
|
||||||
|
op={op}
|
||||||
|
enviando={enviando}
|
||||||
|
onEtapa={(etapa, fecha) =>
|
||||||
|
void ejecutar(() => cambiarEtapaOportunidad(op.id, etapa, fecha), 'No se pudo actualizar la oportunidad')
|
||||||
|
}
|
||||||
|
onCotizar={() => void cotizarOportunidad(op)}
|
||||||
|
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tabCrm === 'apartados' &&
|
||||||
|
(resumen.misApartados.length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="Sin apartados asignados" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{resumen.misApartados.map((a) => (
|
||||||
|
<FilaApartado
|
||||||
|
key={a.id}
|
||||||
|
apartado={a}
|
||||||
|
enviando={enviando}
|
||||||
|
onFase={(fase) => void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Mis capturas de afluencia">
|
<section aria-label="Mis capturas de afluencia">
|
||||||
|
|||||||
@@ -64,3 +64,24 @@ export function descartarLead(itemId: string, motivo: string, comentario?: strin
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST /oportunidades/:itemId/etapa — botones de fase de "Mis oportunidades" */
|
||||||
|
export function cambiarEtapaOportunidad(itemId: string, etapa: string, fecha?: string): Promise<unknown> {
|
||||||
|
return fetchJson(`/oportunidades/${itemId}/etapa`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
etapa,
|
||||||
|
...(fecha !== undefined && fecha !== '' ? { fecha } : {}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /apartados/:itemId/estado — botones de fase de "Mis apartados" */
|
||||||
|
export function cambiarEstadoApartado(itemId: string, estado: string): Promise<unknown> {
|
||||||
|
return fetchJson(`/apartados/${itemId}/estado`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ estado }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user