feat(apartado): ficha completa en Monday + estado visible en la app
- El alta (directa y progresiva) llena el Apartado con telefono, correo, curp, Precio Final, Enganche, Fecha Compra, Vendedor (el capturista con Monday vinculado) y Forma de Pago; el path webhook/cron hereda contacto, precio y asesor de la oportunidad. - Bidireccional: Mis clientes en /hoy muestra el estado actual del Apartado leyendolo de Monday (si lo mueven en Monday, la app lo refleja en el siguiente refresh). - getApartados con la correccion value-null al leer Estado. - Test del mapa de Tipo propuesta del cotizador (los 502 del log eran de un proceso previo al fix 4f31d1c; queda cubierto).
This commit is contained in:
@@ -10,6 +10,9 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
|
usuario: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mondayMock = vi.hoisted(() => ({
|
const mondayMock = vi.hoisted(() => ({
|
||||||
@@ -66,6 +69,7 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||||
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||||
|
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
|
||||||
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
||||||
mondayMock.actualizarContacto.mockResolvedValue(undefined)
|
mondayMock.actualizarContacto.mockResolvedValue(undefined)
|
||||||
mondayMock.crearApartado.mockResolvedValue('ap-1')
|
mondayMock.crearApartado.mockResolvedValue('ap-1')
|
||||||
@@ -282,6 +286,38 @@ describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () =>
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('el Apartado lleva la ficha completa: contacto, precio, Forma de Pago y Vendedor del capturista original', async () => {
|
||||||
|
const { completarProgreso } = await import('./clientes.controller.js')
|
||||||
|
prismaMock.cliente.findUnique.mockResolvedValue({
|
||||||
|
id: 'cli-1',
|
||||||
|
mondayContactoId: 'cont-1',
|
||||||
|
mondayApartadoId: null,
|
||||||
|
capturadoPorId: 'usr-9', // quien inició el alta progresiva
|
||||||
|
})
|
||||||
|
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||||
|
const altaConCredito = {
|
||||||
|
...altaCompleta,
|
||||||
|
viviendaCredito: { ...altaCompleta.viviendaCredito, tiposCredito: ['COFI_FOVISSSTE'] },
|
||||||
|
}
|
||||||
|
const { res, status } = resFake()
|
||||||
|
await completarProgreso(reqFake(altaConCredito, { id: 'cli-1' }), res)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
clienteNombre: 'Ana Demo Progreso',
|
||||||
|
unidadCodigo: 'U-999-DEMO2',
|
||||||
|
telefono: '6649990003',
|
||||||
|
email: 'ana@correo.com',
|
||||||
|
curp: 'AEDA900101MBCXXX00',
|
||||||
|
precioFinal: 1500000,
|
||||||
|
enganche: 20000,
|
||||||
|
vendedorId: 12345,
|
||||||
|
formaPagoLabel: 'Cofi-Fovissste',
|
||||||
|
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => {
|
it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => {
|
||||||
const { completarProgreso } = await import('./clientes.controller.js')
|
const { completarProgreso } = await import('./clientes.controller.js')
|
||||||
prismaMock.cliente.findUnique.mockResolvedValue({
|
prismaMock.cliente.findUnique.mockResolvedValue({
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
|
usuario: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mondayMock = vi.hoisted(() => ({
|
const mondayMock = vi.hoisted(() => ({
|
||||||
@@ -134,6 +137,7 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||||
prismaMock.cliente.update.mockResolvedValue({})
|
prismaMock.cliente.update.mockResolvedValue({})
|
||||||
|
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
|
||||||
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
||||||
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
|
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
|
||||||
})
|
})
|
||||||
@@ -176,3 +180,99 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('formaPagoLabel (tipos de crédito del alta → dropdown Forma de Pago)', () => {
|
||||||
|
it('mapea los 8 valores del enum del formato a los labels sembrados en Monday', async () => {
|
||||||
|
const { formaPagoLabel } = await import('./clientes.controller.js')
|
||||||
|
expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit')
|
||||||
|
expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit')
|
||||||
|
expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit')
|
||||||
|
expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito')
|
||||||
|
expect(formaPagoLabel(['CONTADO'])).toBe('Contado')
|
||||||
|
expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste')
|
||||||
|
expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste')
|
||||||
|
expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => {
|
||||||
|
const { formaPagoLabel } = await import('./clientes.controller.js')
|
||||||
|
expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado')
|
||||||
|
expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined()
|
||||||
|
expect(formaPagoLabel([])).toBeUndefined()
|
||||||
|
expect(formaPagoLabel(undefined)).toBeUndefined()
|
||||||
|
expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('crearCliente — Apartado con ficha completa', () => {
|
||||||
|
const altaConVenta = {
|
||||||
|
datosGenerales: {
|
||||||
|
nombreCompleto: 'Cliente Demo',
|
||||||
|
curp: 'CURP123',
|
||||||
|
rfc: 'RFC123',
|
||||||
|
email: 'cli@correo.com',
|
||||||
|
celular: '6641112233',
|
||||||
|
},
|
||||||
|
viviendaCredito: {
|
||||||
|
proyecto: 'SENDA',
|
||||||
|
ubicacion: 'U-101',
|
||||||
|
valorVenta: 1500000,
|
||||||
|
enganche: 20000,
|
||||||
|
tiposCredito: ['INFONAVIT'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqFake = (body: unknown, usuarioToken?: { sub: string }): Request =>
|
||||||
|
({ body, usuarioToken } as unknown as Request)
|
||||||
|
const resFake = () => {
|
||||||
|
const json = vi.fn()
|
||||||
|
const status = vi.fn().mockReturnValue({ json })
|
||||||
|
return { res: { json, status } as unknown as Response, status, json }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||||
|
prismaMock.cliente.update.mockResolvedValue({})
|
||||||
|
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||||
|
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
||||||
|
mondayMock.crearApartado.mockResolvedValue('ap-1')
|
||||||
|
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => {
|
||||||
|
const { crearCliente } = await import('./clientes.controller.js')
|
||||||
|
const { res, status, json } = resFake()
|
||||||
|
await crearCliente(reqFake(altaConVenta), res)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
clienteNombre: 'Cliente Demo',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
montoApartado: 20000,
|
||||||
|
estadoLabel: 'Pago Apartado',
|
||||||
|
telefono: '6641112233',
|
||||||
|
email: 'cli@correo.com',
|
||||||
|
curp: 'CURP123',
|
||||||
|
precioFinal: 1500000,
|
||||||
|
enganche: 20000,
|
||||||
|
formaPagoLabel: 'Infonavit',
|
||||||
|
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'ap-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('con capturista vinculado a Monday, el Apartado lo lleva como Vendedor (people)', async () => {
|
||||||
|
const { crearCliente } = await import('./clientes.controller.js')
|
||||||
|
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||||
|
await crearCliente(reqFake(altaConVenta, { sub: 'usr-1' }), resFake().res)
|
||||||
|
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: 12345 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin capturista vinculado, el Apartado se crea sin Vendedor', async () => {
|
||||||
|
const { crearCliente } = await import('./clientes.controller.js')
|
||||||
|
await crearCliente(reqFake(altaConVenta), resFake().res)
|
||||||
|
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -181,14 +181,55 @@ async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Item en el tablero Apartado (pipeline Enkontrol); solo si hay valor de venta */
|
/** Label del dropdown "Forma de Pago" (tablero Apartado) a partir del enum del formato de alta */
|
||||||
|
const FORMA_PAGO_LABEL: Record<string, string> = {
|
||||||
|
INFONAVIT: 'Infonavit',
|
||||||
|
APOYO_INFONAVIT: 'Apoyo Infonavit',
|
||||||
|
COFINAVIT: 'Cofinavit',
|
||||||
|
SEGUNDO_CREDITO: 'Segundo crédito',
|
||||||
|
CONTADO: 'Contado',
|
||||||
|
FOVISSSTE: 'Fovissste',
|
||||||
|
COFI_FOVISSSTE: 'Cofi-Fovissste',
|
||||||
|
BANCARIO: 'Bancario',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label de "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo
|
||||||
|
* del wizard; se toma el PRIMERO reconocido — la columna del board es de una
|
||||||
|
* sola forma de pago). undefined si no hay ninguno reconocible.
|
||||||
|
*/
|
||||||
|
export function formaPagoLabel(tiposCredito: unknown): string | undefined {
|
||||||
|
if (!Array.isArray(tiposCredito)) return undefined
|
||||||
|
for (const t of tiposCredito) {
|
||||||
|
const label = FORMA_PAGO_LABEL[String(t).trim().toUpperCase()]
|
||||||
|
if (label !== undefined) return label
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */
|
||||||
async function crearApartadoSiAplica(
|
async function crearApartadoSiAplica(
|
||||||
dg: { nombreCompleto: string },
|
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
|
||||||
vc: { ubicacion?: string | undefined; valorVenta?: string | number | undefined; enganche?: string | number | undefined },
|
vc: {
|
||||||
|
ubicacion?: string | undefined
|
||||||
|
valorVenta?: string | number | undefined
|
||||||
|
enganche?: string | number | undefined
|
||||||
|
tiposCredito?: unknown
|
||||||
|
},
|
||||||
|
capturadoPorId: string | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const valorVenta = aNumero(vc.valorVenta)
|
const valorVenta = aNumero(vc.valorVenta)
|
||||||
if (valorVenta <= 0) return null
|
if (valorVenta <= 0) return null
|
||||||
const enganche = aNumero(vc.enganche)
|
const enganche = aNumero(vc.enganche)
|
||||||
|
// Vendedor del apartado: el usuario Monday vinculado de quien capturó el alta
|
||||||
|
let vendedorId: number | undefined
|
||||||
|
if (capturadoPorId !== null) {
|
||||||
|
const capturista = await prisma.usuario.findUnique({
|
||||||
|
where: { id: capturadoPorId },
|
||||||
|
select: { mondayUserId: true },
|
||||||
|
})
|
||||||
|
vendedorId = capturista?.mondayUserId ?? undefined
|
||||||
|
}
|
||||||
return mondayService.crearApartado({
|
return mondayService.crearApartado({
|
||||||
clienteNombre: dg.nombreCompleto,
|
clienteNombre: dg.nombreCompleto,
|
||||||
unidadCodigo: vc.ubicacion ?? '',
|
unidadCodigo: vc.ubicacion ?? '',
|
||||||
@@ -196,6 +237,14 @@ async function crearApartadoSiAplica(
|
|||||||
fechaApartado: hoyMas(0),
|
fechaApartado: hoyMas(0),
|
||||||
fechaLimite: hoyMas(15),
|
fechaLimite: hoyMas(15),
|
||||||
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
|
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
|
||||||
|
telefono: dg.celular,
|
||||||
|
email: dg.email,
|
||||||
|
curp: dg.curp,
|
||||||
|
precioFinal: valorVenta,
|
||||||
|
enganche: enganche > 0 ? enganche : undefined,
|
||||||
|
fechaCompra: hoyMas(0),
|
||||||
|
vendedorId,
|
||||||
|
formaPagoLabel: formaPagoLabel(vc.tiposCredito),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +290,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
|||||||
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
||||||
})
|
})
|
||||||
|
|
||||||
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
|
mondayApartadoId = await crearApartadoSiAplica(dg, vc, req.usuarioToken?.sub ?? null)
|
||||||
|
|
||||||
await prisma.cliente.update({
|
await prisma.cliente.update({
|
||||||
where: { id: cliente.id },
|
where: { id: cliente.id },
|
||||||
@@ -444,7 +493,8 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mondayApartadoId === null) {
|
if (mondayApartadoId === null) {
|
||||||
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
|
// El capturista original del alta progresiva es quien queda como Vendedor
|
||||||
|
mondayApartadoId = await crearApartadoSiAplica(dg, vc, cliente.capturadoPorId)
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.cliente.update({
|
await prisma.cliente.update({
|
||||||
|
|||||||
@@ -112,3 +112,15 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', ()
|
|||||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
|
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
|
||||||
|
it('los 5 códigos del enum mapean a los labels sembrados en la columna "Tipo propuesta"', async () => {
|
||||||
|
const { LABEL_PROPUESTA_MONDAY } = await import('./cotizaciones.controller.js')
|
||||||
|
// Labels reales verificados en Monday (difieren en guiones/espacios)
|
||||||
|
expect(LABEL_PROPUESTA_MONDAY['BANCARIO']).toBe('CREDITO BANCARIO')
|
||||||
|
expect(LABEL_PROPUESTA_MONDAY['INFONAVIT']).toBe('INFONAVIT-COFINAVIT')
|
||||||
|
expect(LABEL_PROPUESTA_MONDAY['COFINAVIT']).toBe('INFONAVIT-COFINAVIT')
|
||||||
|
expect(LABEL_PROPUESTA_MONDAY['CONTADO']).toBe('CONTADO')
|
||||||
|
expect(LABEL_PROPUESTA_MONDAY['SEMI_CONTADO']).toBe('SEMI CONTADO')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ const hoyISO = (): string => {
|
|||||||
return `${d.getFullYear()}-${m}-${dia}`
|
return `${d.getFullYear()}-${m}-${dia}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Labels reales de la columna status "Tipo de Propuesta" del tablero Oportunidades
|
// Labels reales de la columna status "Tipo de Propuesta" del tablero
|
||||||
const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
|
// Oportunidades (verificados en Monday: difieren de los códigos del cotizador
|
||||||
|
// en guiones/espacios — el resolver case-insensitive solo no alcanza)
|
||||||
|
export const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
|
||||||
BANCARIO: 'CREDITO BANCARIO',
|
BANCARIO: 'CREDITO BANCARIO',
|
||||||
INFONAVIT: 'INFONAVIT-COFINAVIT',
|
INFONAVIT: 'INFONAVIT-COFINAVIT',
|
||||||
COFINAVIT: 'INFONAVIT-COFINAVIT',
|
COFINAVIT: 'INFONAVIT-COFINAVIT',
|
||||||
|
|||||||
@@ -632,6 +632,25 @@ describe('AutomatizacionesService.escaneoOportunidades', () => {
|
|||||||
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
|
expect(state.mapa.get('contador:apartadosCreados')).toBe('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('el Apartado hereda de la oportunidad: contacto, Precio Final y Vendedor (primer asesor)', async () => {
|
||||||
|
const monday = mondayMock([], [
|
||||||
|
oportunidad('10', { etapa: 'Aparto', telefono: '6641112233', email: 'c@c.com', asesorIds: [101, 102] }),
|
||||||
|
])
|
||||||
|
const state = stateEnMemoria()
|
||||||
|
const svc = crearServicio(monday, state)
|
||||||
|
|
||||||
|
const r = await svc.escaneoOportunidades()
|
||||||
|
expect(r.apartadosCreados).toHaveLength(1)
|
||||||
|
expect(monday.apartados[0]).toMatchObject({
|
||||||
|
clienteNombre: 'Cliente 10',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
telefono: '6641112233',
|
||||||
|
email: 'c@c.com',
|
||||||
|
precioFinal: 2500000,
|
||||||
|
vendedorId: 101,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
|
it('una oportunidad en Ganado crea su item en Ventas con unidad y valor', async () => {
|
||||||
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
|
const monday = mondayMock([], [oportunidad('11', { etapa: 'GANADO' })])
|
||||||
const state = stateEnMemoria()
|
const state = stateEnMemoria()
|
||||||
|
|||||||
@@ -541,12 +541,18 @@ export class AutomatizacionesService {
|
|||||||
for (const op of oportunidades) {
|
for (const op of oportunidades) {
|
||||||
if (esEtapa(op.etapa, ETAPA_APARTO) && (await this.state.get(claveApartadoCreado(op.id))) === null) {
|
if (esEtapa(op.etapa, ETAPA_APARTO) && (await this.state.get(claveApartadoCreado(op.id))) === null) {
|
||||||
try {
|
try {
|
||||||
|
// El Apartado hereda lo que la oportunidad ya trae (contacto, precio,
|
||||||
|
// asesor como Vendedor); lo que no exista se omite
|
||||||
const apartadoItemId = await this.monday.crearApartado({
|
const apartadoItemId = await this.monday.crearApartado({
|
||||||
clienteNombre: op.nombre,
|
clienteNombre: op.nombre,
|
||||||
unidadCodigo: op.unidadCodigo,
|
unidadCodigo: op.unidadCodigo,
|
||||||
fechaApartado: AutomatizacionesService.fechaHoyMas(0),
|
fechaApartado: AutomatizacionesService.fechaHoyMas(0),
|
||||||
fechaLimite: AutomatizacionesService.fechaHoyMas(7),
|
fechaLimite: AutomatizacionesService.fechaHoyMas(7),
|
||||||
estadoLabel: 'Pago Apartado',
|
estadoLabel: 'Pago Apartado',
|
||||||
|
telefono: op.telefono !== '' ? op.telefono : undefined,
|
||||||
|
email: op.email !== '' ? op.email : undefined,
|
||||||
|
precioFinal: op.precioFinal > 0 ? op.precioFinal : undefined,
|
||||||
|
vendedorId: op.asesorIds[0],
|
||||||
})
|
})
|
||||||
await this.state.set(claveApartadoCreado(op.id), apartadoItemId)
|
await this.state.set(claveApartadoCreado(op.id), apartadoItemId)
|
||||||
resultado.apartadosCreados.push({ oportunidadId: op.id, nombre: op.nombre, apartadoItemId })
|
resultado.apartadosCreados.push({ oportunidadId: op.id, nombre: op.nombre, apartadoItemId })
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
nombreCompleto: 'Ana En Captura',
|
nombreCompleto: 'Ana En Captura',
|
||||||
proyecto: '',
|
proyecto: '',
|
||||||
unidadCodigo: null,
|
unidadCodigo: null,
|
||||||
|
mondayApartadoId: null,
|
||||||
createdAt: new Date('2026-08-26T09:00:00Z'),
|
createdAt: new Date('2026-08-26T09:00:00Z'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -317,6 +318,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
nombreCompleto: 'Luis Completado',
|
nombreCompleto: 'Luis Completado',
|
||||||
proyecto: 'DIAGONAL',
|
proyecto: 'DIAGONAL',
|
||||||
unidadCodigo: 'U-101',
|
unidadCodigo: 'U-101',
|
||||||
|
mondayApartadoId: null,
|
||||||
createdAt: new Date('2026-08-27T09:00:00Z'),
|
createdAt: new Date('2026-08-27T09:00:00Z'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -329,6 +331,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
unidadCodigo: 'U-101',
|
unidadCodigo: 'U-101',
|
||||||
completado: true,
|
completado: true,
|
||||||
capturadoEn: '2026-08-27T09:00:00.000Z',
|
capturadoEn: '2026-08-27T09:00:00.000Z',
|
||||||
|
estadoApartado: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'c1',
|
id: 'c1',
|
||||||
@@ -337,13 +340,57 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => {
|
|||||||
unidadCodigo: '',
|
unidadCodigo: '',
|
||||||
completado: false,
|
completado: false,
|
||||||
capturadoEn: '2026-08-26T09:00:00.000Z',
|
capturadoEn: '2026-08-26T09:00:00.000Z',
|
||||||
|
estadoApartado: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('mis clientes: estadoApartado cruza mondayApartadoId contra el tablero Apartado', async () => {
|
||||||
|
const clientes = [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
nombreCompleto: 'Con Apartado',
|
||||||
|
proyecto: 'DIAGONAL',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
mondayApartadoId: 'ap-1',
|
||||||
|
createdAt: new Date('2026-08-27T09:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c2',
|
||||||
|
nombreCompleto: 'Apartado Borrado',
|
||||||
|
proyecto: 'DIAGONAL',
|
||||||
|
unidadCodigo: 'U-102',
|
||||||
|
mondayApartadoId: 'ap-fantasma', // ya no existe en Monday
|
||||||
|
createdAt: new Date('2026-08-26T09:00:00Z'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c3',
|
||||||
|
nombreCompleto: 'Sin Apartado',
|
||||||
|
proyecto: '',
|
||||||
|
unidadCodigo: null,
|
||||||
|
mondayApartadoId: null,
|
||||||
|
createdAt: new Date('2026-08-25T09:00:00Z'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const monday = mondayMock({ apartados: [apartado({ id: 'ap-1', estado: 'Alta Enkontrol' })] })
|
||||||
|
const r = await resumenMiDia(monday, vendedor, new Map(), [], clientes, AHORA)
|
||||||
|
expect(r.misClientes.map((c) => [c.id, c.estadoApartado])).toEqual([
|
||||||
|
['c1', 'Alta Enkontrol'], // estado real del item en Monday
|
||||||
|
['c2', null], // el apartado ya no existe
|
||||||
|
['c3', null], // sin apartado ligado
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('mis clientes: proyecto con solo espacios cuenta como en captura', async () => {
|
it('mis clientes: proyecto con solo espacios cuenta como en captura', async () => {
|
||||||
const clientes = [
|
const clientes = [
|
||||||
{ id: 'c1', nombreCompleto: 'Espacios', proyecto: ' ', unidadCodigo: null, createdAt: new Date('2026-08-26T09:00:00Z') },
|
{
|
||||||
|
id: 'c1',
|
||||||
|
nombreCompleto: 'Espacios',
|
||||||
|
proyecto: ' ',
|
||||||
|
unidadCodigo: null,
|
||||||
|
mondayApartadoId: null,
|
||||||
|
createdAt: new Date('2026-08-26T09:00:00Z'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
|
const r = await resumenMiDia(mondayMock({}), vendedor, new Map(), [], clientes, AHORA)
|
||||||
expect(r.misClientes[0]?.completado).toBe(false)
|
expect(r.misClientes[0]?.completado).toBe(false)
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface ClienteMiDia {
|
|||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE)
|
completado: boolean // true cuando el alta ya tiene proyecto (pasó a CLIENTE)
|
||||||
capturadoEn: string // ISO
|
capturadoEn: string // ISO
|
||||||
|
/** Estado actual del item en el tablero Apartado (bidireccional Monday→app); null si no hay apartado ligado o ya no existe */
|
||||||
|
estadoApartado: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
@@ -136,7 +138,14 @@ export async function resumenMiDia(
|
|||||||
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
/** Capturas locales del usuario (tabla LeadCaptura), ya filtradas por él */
|
||||||
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
|
capturas: { leadId: string; nombreLead: string; telefono: string | null; createdAt: Date }[] = [],
|
||||||
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
|
/** Clientes cuyo alta inició el usuario (tabla Cliente), ya filtrados por él */
|
||||||
clientes: { id: string; nombreCompleto: string; proyecto: string; unidadCodigo: string | null; createdAt: Date }[] = [],
|
clientes: {
|
||||||
|
id: string
|
||||||
|
nombreCompleto: string
|
||||||
|
proyecto: string
|
||||||
|
unidadCodigo: string | null
|
||||||
|
mondayApartadoId: string | null
|
||||||
|
createdAt: Date
|
||||||
|
}[] = [],
|
||||||
ahora: Date = new Date(),
|
ahora: Date = new Date(),
|
||||||
): Promise<ResumenMiDia> {
|
): Promise<ResumenMiDia> {
|
||||||
const miId = usuario.mondayUserId
|
const miId = usuario.mondayUserId
|
||||||
@@ -217,8 +226,11 @@ export async function resumenMiDia(
|
|||||||
})
|
})
|
||||||
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
||||||
|
|
||||||
// "Mis clientes" es 100% local (no requiere Monday): el alta en progreso
|
// "Mis clientes" es local + cruce con Monday: el alta en progreso lleva
|
||||||
// lleva proyecto '', el alta completada lleva el proyecto lleno
|
// proyecto '', el alta completada lleva el proyecto lleno. estadoApartado
|
||||||
|
// cruza Cliente.mondayApartadoId contra el tablero Apartado (null si no hay
|
||||||
|
// apartado ligado o ya no existe en Monday)
|
||||||
|
const apartadosPorId = new Map(apartados.map((a) => [a.id, a]))
|
||||||
const misClientes: ClienteMiDia[] = clientes
|
const misClientes: ClienteMiDia[] = clientes
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -227,6 +239,8 @@ export async function resumenMiDia(
|
|||||||
unidadCodigo: c.unidadCodigo ?? '',
|
unidadCodigo: c.unidadCodigo ?? '',
|
||||||
completado: c.proyecto.trim() !== '',
|
completado: c.proyecto.trim() !== '',
|
||||||
capturadoEn: c.createdAt.toISOString(),
|
capturadoEn: c.createdAt.toISOString(),
|
||||||
|
estadoApartado:
|
||||||
|
c.mondayApartadoId != null ? (apartadosPorId.get(c.mondayApartadoId)?.estado ?? null) : null,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
.sort((a, b) => b.capturadoEn.localeCompare(a.capturadoEn))
|
||||||
|
|
||||||
|
|||||||
@@ -743,4 +743,99 @@ describe('MondayService', () => {
|
|||||||
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
|
expect(Object.keys(parsed).sort()).toEqual(['color_estado', 'date_fecha'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('crearApartado (ficha completa del tablero Apartado)', () => {
|
||||||
|
// Columnas reales del board (verificadas en Monday 2026-09-04)
|
||||||
|
const COLUMNAS_APARTADO = [
|
||||||
|
{ id: 'text_u', title: 'Unidad', type: 'text' },
|
||||||
|
{ id: 'numeric_ma', title: 'Monto apartado', type: 'numbers' },
|
||||||
|
{ id: 'date_fa', title: 'Fecha apartado', type: 'date' },
|
||||||
|
{ id: 'date_fl', title: 'Fecha límite', type: 'date' },
|
||||||
|
{ id: 'color_e', title: 'Estado', type: 'status' },
|
||||||
|
{ id: 'phone_t', title: 'Teléfono', type: 'phone' },
|
||||||
|
{ id: 'email_c', title: 'Correo electrónico', type: 'email' },
|
||||||
|
{ id: 'text_curp', title: 'curp', type: 'text' }, // minúscula en el board
|
||||||
|
{ id: 'numeric_pf', title: 'Precio Final', type: 'numbers' },
|
||||||
|
{ id: 'numeric_en', title: 'Enganche', type: 'numbers' },
|
||||||
|
{ id: 'date_fc', title: 'Fecha Compra', type: 'date' },
|
||||||
|
{ id: 'people_v', title: 'Vendedor', type: 'people' },
|
||||||
|
{ id: 'dropdown_fp', title: 'Forma de Pago', type: 'dropdown' },
|
||||||
|
{ id: 'long_obs', title: 'Observaciones', type: 'long_text' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('serializa cada columna según su tipo (phone, email, numbers, date, people, dropdown, long_text)', async () => {
|
||||||
|
const llamadas = mockearFetch((body) => {
|
||||||
|
if (body.query.includes('settings_str')) {
|
||||||
|
return respuestaOk({
|
||||||
|
boards: [
|
||||||
|
{
|
||||||
|
columns: [
|
||||||
|
{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' },
|
||||||
|
{ id: 'dropdown_fp', title: 'Forma de Pago', settings_str: '{"labels":[{"name":"Infonavit"},{"name":"Contado"}]}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] })
|
||||||
|
return respuestaOk({ create_item: { id: 'ap-1' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = await crearServicio().crearApartado({
|
||||||
|
clienteNombre: 'Cliente Demo',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
montoApartado: 20000,
|
||||||
|
fechaApartado: '2026-09-04',
|
||||||
|
fechaLimite: '2026-09-19',
|
||||||
|
estadoLabel: 'Pago Apartado',
|
||||||
|
telefono: '6641112233',
|
||||||
|
email: 'cli@correo.com',
|
||||||
|
curp: 'CURP123',
|
||||||
|
precioFinal: 1500000,
|
||||||
|
enganche: 20000,
|
||||||
|
fechaCompra: '2026-09-04',
|
||||||
|
vendedorId: 12345,
|
||||||
|
formaPagoLabel: 'INFONAVIT', // el resolver lo ajusta al label real
|
||||||
|
observaciones: 'Venta con descuento autorizado',
|
||||||
|
})
|
||||||
|
expect(id).toBe('ap-1')
|
||||||
|
|
||||||
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
||||||
|
expect(mutacion?.body.variables['boardId']).toBe('666') // tablero Apartado
|
||||||
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
||||||
|
expect(parsed['phone_t']).toEqual({ phone: '6641112233', countryShortName: 'MX' })
|
||||||
|
expect(parsed['email_c']).toEqual({ email: 'cli@correo.com', text: 'cli@correo.com' })
|
||||||
|
expect(parsed['text_curp']).toBe('CURP123')
|
||||||
|
expect(parsed['numeric_pf']).toBe(1500000)
|
||||||
|
expect(parsed['numeric_en']).toBe(20000)
|
||||||
|
expect(parsed['date_fc']).toEqual({ date: '2026-09-04' })
|
||||||
|
expect(parsed['people_v']).toEqual({ personsAndTeams: [{ id: 12345, kind: 'person' }] })
|
||||||
|
expect(parsed['dropdown_fp']).toEqual({ labels: ['Infonavit'] }) // dropdown resuelto case-insensitive
|
||||||
|
expect(parsed['long_obs']).toEqual({ text: 'Venta con descuento autorizado' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omite las columnas opcionales no capturadas', async () => {
|
||||||
|
const llamadas = mockearFetch((body) => {
|
||||||
|
if (body.query.includes('settings_str')) {
|
||||||
|
return respuestaOk({
|
||||||
|
boards: [{ columns: [{ id: 'color_e', title: 'Estado', settings_str: '{"labels":{"1":"Pago Apartado"}}' }] }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_APARTADO }] })
|
||||||
|
return respuestaOk({ create_item: { id: 'ap-2' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
await crearServicio().crearApartado({
|
||||||
|
clienteNombre: 'Cliente Demo',
|
||||||
|
unidadCodigo: 'U-101',
|
||||||
|
fechaApartado: '2026-09-04',
|
||||||
|
fechaLimite: '2026-09-19',
|
||||||
|
estadoLabel: 'Pago Apartado',
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutacion = llamadas.find((l) => l.body.query.includes('create_item'))
|
||||||
|
const parsed = JSON.parse(String(mutacion?.body.variables['columnValues'])) as Record<string, unknown>
|
||||||
|
expect(Object.keys(parsed).sort()).toEqual(['color_e', 'date_fa', 'date_fl', 'text_u'])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -557,12 +557,25 @@ export class MondayService {
|
|||||||
|
|
||||||
async crearApartado(datos: DatosApartado): Promise<string> {
|
async crearApartado(datos: DatosApartado): Promise<string> {
|
||||||
const boardId = config.monday.boards.apartado
|
const boardId = config.monday.boards.apartado
|
||||||
|
// Mapeo a las columnas reales del tablero Apartado (verificadas en Monday):
|
||||||
|
// contacto (Teléfono/Correo/curp), operación (Precio Final/Enganche/
|
||||||
|
// Fecha Compra/Forma de Pago), asignación (Vendedor) y Observaciones.
|
||||||
|
// Todo lo opcional se omite si viene undefined (mismo patrón de siempre).
|
||||||
const columnValues = await this.construirColumnValues(boardId, {
|
const columnValues = await this.construirColumnValues(boardId, {
|
||||||
Unidad: datos.unidadCodigo,
|
Unidad: datos.unidadCodigo,
|
||||||
'Monto apartado': datos.montoApartado,
|
'Monto apartado': datos.montoApartado,
|
||||||
'Fecha apartado': datos.fechaApartado,
|
'Fecha apartado': datos.fechaApartado,
|
||||||
'Fecha límite': datos.fechaLimite,
|
'Fecha límite': datos.fechaLimite,
|
||||||
Estado: datos.estadoLabel,
|
Estado: datos.estadoLabel,
|
||||||
|
Teléfono: datos.telefono,
|
||||||
|
'Correo electrónico': datos.email,
|
||||||
|
curp: datos.curp, // el título de la columna es minúscula en el board
|
||||||
|
'Precio Final': datos.precioFinal,
|
||||||
|
Enganche: datos.enganche,
|
||||||
|
'Fecha Compra': datos.fechaCompra,
|
||||||
|
Vendedor: datos.vendedorId,
|
||||||
|
'Forma de Pago': datos.formaPagoLabel, // dropdown; el resolver ajusta case
|
||||||
|
Observaciones: datos.observaciones,
|
||||||
})
|
})
|
||||||
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
return this.crearItem(boardId, datos.clienteNombre, columnValues)
|
||||||
}
|
}
|
||||||
@@ -1001,12 +1014,16 @@ export class MondayService {
|
|||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
const porId = new Map(item.column_values.map((cv) => [cv.id, cv]))
|
||||||
|
// Misma corrección que mapearLead: status nunca escrito → value null y
|
||||||
|
// `text` muestra el default del tablero; el estado real es vacío
|
||||||
|
const estadoCv = colEstado ? porId.get(colEstado.id) : undefined
|
||||||
|
const estado = estadoCv?.value ? (estadoCv.text ?? '') : ''
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
nombre: item.name,
|
nombre: item.name,
|
||||||
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: colEstado ? (porId.get(colEstado.id)?.text ?? '') : '',
|
estado,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1279,16 @@ export interface DatosApartado {
|
|||||||
fechaApartado: string // 'YYYY-MM-DD'
|
fechaApartado: string // 'YYYY-MM-DD'
|
||||||
fechaLimite: string // 'YYYY-MM-DD'
|
fechaLimite: string // 'YYYY-MM-DD'
|
||||||
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
|
estadoLabel: string // p. ej. 'Pago Apartado' (pipeline Enkontrol)
|
||||||
|
// Contacto y operación (opcionales; columnas reales del tablero Apartado)
|
||||||
|
telefono?: string | undefined // phone 'Teléfono'
|
||||||
|
email?: string | undefined // email 'Correo electrónico'
|
||||||
|
curp?: string | undefined // text 'curp' (título minúscula en el board)
|
||||||
|
precioFinal?: number | undefined // numbers 'Precio Final'
|
||||||
|
enganche?: number | undefined // numbers 'Enganche'
|
||||||
|
fechaCompra?: string | undefined // date 'Fecha Compra' ('YYYY-MM-DD')
|
||||||
|
vendedorId?: number | undefined // people 'Vendedor' (user ID de Monday)
|
||||||
|
formaPagoLabel?: string | undefined // dropdown 'Forma de Pago' (labels ya sembrados)
|
||||||
|
observaciones?: string | undefined // long_text 'Observaciones'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatosVenta {
|
export interface DatosVenta {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface ClienteMiDia {
|
|||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
completado: boolean
|
completado: boolean
|
||||||
capturadoEn: string // ISO
|
capturadoEn: string // ISO
|
||||||
|
/** Estado actual del item en el tablero Apartado (Monday); null si no hay apartado ligado */
|
||||||
|
estadoApartado: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
|
|||||||
@@ -439,6 +439,12 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Estado del apartado en Monday (bidireccional); solo si hay apartado ligado */}
|
||||||
|
{c.estadoApartado !== null && (
|
||||||
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
{c.estadoApartado}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
c.completado
|
c.completado
|
||||||
|
|||||||
Reference in New Issue
Block a user