feat(crm): fecha de alta en cards y bitacora, click en kanban abre bitacora, tab Descartados con reasignacion para admin, tareas al calendario, estatus No asistio y edicion de unidades del inventario
This commit is contained in:
@@ -128,6 +128,16 @@ export async function eliminarUnidadEndpoint(req: Request, res: Response): Promi
|
|||||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
|
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m², modelo y estatus */
|
||||||
|
export async function actualizarUnidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||||
|
const desarrolloId = idDe(req)
|
||||||
|
const codigo = String(req.params['codigo'])
|
||||||
|
const body = service.actualizarUnidadSchema.parse(req.body)
|
||||||
|
const unidad = await service.actualizarUnidad(desarrolloId, codigo, body)
|
||||||
|
res.json(unidad)
|
||||||
|
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_UNIDAD', 'unidad', codigo)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Público (micrositio) ──────────────────────────────────────────────
|
// ── Público (micrositio) ──────────────────────────────────────────────
|
||||||
|
|
||||||
/** GET /api/publico/desarrollos */
|
/** GET /api/publico/desarrollos */
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
actualizarDesarrolloEndpoint,
|
actualizarDesarrolloEndpoint,
|
||||||
actualizarModeloEndpoint,
|
actualizarModeloEndpoint,
|
||||||
|
actualizarUnidadEndpoint,
|
||||||
agregarModeloEndpoint,
|
agregarModeloEndpoint,
|
||||||
crearDesarrolloEndpoint,
|
crearDesarrolloEndpoint,
|
||||||
crearUnidadesEndpoint,
|
crearUnidadesEndpoint,
|
||||||
@@ -409,4 +410,5 @@ apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEnd
|
|||||||
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
|
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
|
||||||
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
|
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
|
||||||
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
|
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
|
||||||
|
apiRouter.patch('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, actualizarUnidadEndpoint)
|
||||||
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)
|
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)
|
||||||
|
|||||||
@@ -168,6 +168,24 @@ export const crearUnidadesSchema = z
|
|||||||
|
|
||||||
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
|
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
|
||||||
|
|
||||||
|
/** Edición de una unidad existente (PATCH): precios, m², ubicación y modelo */
|
||||||
|
export const actualizarUnidadSchema = z
|
||||||
|
.object({
|
||||||
|
manzana: z.string().trim().optional(),
|
||||||
|
lote: z.string().trim().optional(),
|
||||||
|
nivel: z.string().trim().optional(),
|
||||||
|
modeloId: z.string().trim().min(1).optional(),
|
||||||
|
precioLista: z.number().min(0).optional(),
|
||||||
|
precioAcabados: z.number().min(0).optional(),
|
||||||
|
supConstruccion: z.number().min(0).optional(),
|
||||||
|
supExcedente: z.number().min(0).optional(),
|
||||||
|
supEstacionamiento: z.number().min(0).optional(),
|
||||||
|
status: z.string().trim().min(1).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type ActualizarUnidadBody = z.infer<typeof actualizarUnidadSchema>
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ModeloFila {
|
interface ModeloFila {
|
||||||
@@ -635,6 +653,39 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
|
|||||||
await prisma.unidad.delete({ where: { codigo } })
|
await prisma.unidad.delete({ where: { codigo } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m²,
|
||||||
|
* ubicación, modelo y estatus de una unidad existente.
|
||||||
|
*/
|
||||||
|
export async function actualizarUnidad(
|
||||||
|
desarrolloId: string,
|
||||||
|
codigo: string,
|
||||||
|
cambios: ActualizarUnidadBody,
|
||||||
|
) {
|
||||||
|
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
|
||||||
|
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||||
|
|
||||||
|
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
|
||||||
|
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
|
||||||
|
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeloId → nombre del modelo en la tabla Unidad
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
for (const clave of ['manzana', 'lote', 'nivel', 'precioLista', 'precioAcabados', 'supConstruccion', 'supExcedente', 'supEstacionamiento', 'status'] as const) {
|
||||||
|
const valor = cambios[clave]
|
||||||
|
if (valor !== undefined) data[clave] = valor
|
||||||
|
}
|
||||||
|
if (cambios.modeloId !== undefined) {
|
||||||
|
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: cambios.modeloId }, select: { nombre: true } })
|
||||||
|
if (modelo === null) throw new NotFoundError('Modelo no encontrado')
|
||||||
|
data['modelo'] = modelo.nombre
|
||||||
|
}
|
||||||
|
if (Object.keys(data).length === 0) throw new BadRequestError('No hay cambios que aplicar')
|
||||||
|
|
||||||
|
return prisma.unidad.update({ where: { codigo }, data })
|
||||||
|
}
|
||||||
|
|
||||||
// ── Público (micrositio, sin auth) ────────────────────────────────────
|
// ── Público (micrositio, sin auth) ────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface LeadMiDia {
|
|||||||
/** Vendedor asignado (nombre); '' si no tiene */
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
vendedor: string
|
vendedor: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
@@ -131,6 +133,8 @@ export interface OportunidadMiDia {
|
|||||||
modeloInteres: string
|
modeloInteres: string
|
||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
|
||||||
@@ -152,10 +156,32 @@ export interface ApartadoMiDia {
|
|||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registro descartado/abandonado (tab "Descartados" de Mi día, solo admin):
|
||||||
|
* leads descartados, oportunidades perdidas/abandonadas y apartados
|
||||||
|
* cancelados. Reasignable a cualquier vendedor.
|
||||||
|
*/
|
||||||
|
export interface DescartadoMiDia {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
/** De dónde viene: lead | oportunidad | apartado */
|
||||||
|
fuente: 'lead' | 'oportunidad' | 'apartado'
|
||||||
|
etapa: string
|
||||||
|
motivo: string
|
||||||
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
|
vendedor: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
@@ -171,6 +197,8 @@ export interface ResumenMiDia {
|
|||||||
misApartados: ApartadoMiDia[]
|
misApartados: ApartadoMiDia[]
|
||||||
/** Todos los apartados incluyendo terminales (para Escrituración) */
|
/** Todos los apartados incluyendo terminales (para Escrituración) */
|
||||||
todosApartados: ApartadoMiDia[]
|
todosApartados: ApartadoMiDia[]
|
||||||
|
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
|
||||||
|
descartados: DescartadoMiDia[]
|
||||||
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
|
||||||
@@ -212,6 +240,8 @@ export interface FilaLocalMiDia {
|
|||||||
usuarioId: string
|
usuarioId: string
|
||||||
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
|
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
|
||||||
mondayItemId: string | null
|
mondayItemId: string | null
|
||||||
|
/** Motivo de descarte/cancelación (columna motivo de LeadCaptura) */
|
||||||
|
motivo: string | null
|
||||||
pendienteSync: boolean
|
pendienteSync: boolean
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -302,6 +332,7 @@ export function resumenMiDiaLocal(
|
|||||||
desarrollo: c.desarrollo ?? '',
|
desarrollo: c.desarrollo ?? '',
|
||||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
fuente: 'lead' as const,
|
fuente: 'lead' as const,
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
@@ -341,6 +372,7 @@ export function resumenMiDiaLocal(
|
|||||||
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
unidadCodigo,
|
unidadCodigo,
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
fechaCita,
|
fechaCita,
|
||||||
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
|
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
|
||||||
@@ -368,6 +400,7 @@ export function resumenMiDiaLocal(
|
|||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}))
|
}))
|
||||||
@@ -389,6 +422,7 @@ export function resumenMiDiaLocal(
|
|||||||
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
|
||||||
estado: c.etapa,
|
estado: c.etapa,
|
||||||
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
pendienteSync: c.pendienteSync,
|
pendienteSync: c.pendienteSync,
|
||||||
}))
|
}))
|
||||||
@@ -538,6 +572,28 @@ export function resumenMiDiaLocal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Descartados/abandonados (tab "Descartados", solo admin) ---
|
||||||
|
// Leads DESCARTADO/ABANDONO, oportunidades PERDIDO/ABANDONO y apartados
|
||||||
|
// cancelados. Reasignables a cualquier vendedor desde la UI.
|
||||||
|
const esDescartado = (etapa: string): boolean => {
|
||||||
|
const e = etapa.trim().toUpperCase()
|
||||||
|
return e.includes('DESCART') || e.includes('PERDID') || e.includes('ABANDONO') || e.includes('CANCEL')
|
||||||
|
}
|
||||||
|
const descartados: DescartadoMiDia[] = capturas
|
||||||
|
.filter((c) => esDescartado(c.etapa))
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.leadId,
|
||||||
|
nombre: c.nombreLead,
|
||||||
|
telefono: c.telefono ?? '',
|
||||||
|
fuente: c.board === 'LEADS' ? ('lead' as const) : c.board === 'OPORTUNIDADES' ? ('oportunidad' as const) : ('apartado' as const),
|
||||||
|
etapa: c.etapa,
|
||||||
|
motivo: c.motivo ?? '',
|
||||||
|
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
|
||||||
|
creadoEn: c.createdAt.toISOString(),
|
||||||
|
updatedAt: c.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generadoEn: ahora.toISOString(),
|
generadoEn: ahora.toISOString(),
|
||||||
misLeads,
|
misLeads,
|
||||||
@@ -550,6 +606,7 @@ export function resumenMiDiaLocal(
|
|||||||
misApartados,
|
misApartados,
|
||||||
// Todos los apartados incluyendo terminales (para Escrituración)
|
// Todos los apartados incluyendo terminales (para Escrituración)
|
||||||
todosApartados: todosApartadosLocal,
|
todosApartados: todosApartadosLocal,
|
||||||
|
descartados,
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
|
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
|
||||||
(c) => c.createdAt.getTime() >= hace7DiasMs,
|
(c) => c.createdAt.getTime() >= hace7DiasMs,
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
},
|
},
|
||||||
|
actividadLocal: {
|
||||||
|
create: vi.fn(),
|
||||||
|
updateMany: vi.fn(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
@@ -55,6 +59,17 @@ describe('RecordatorioVendedorService', () => {
|
|||||||
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
fechaRecordar: new Date('2026-09-10T10:00:00'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// La tarea también se agenda en el calendario del vendedor
|
||||||
|
expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
titulo: 'Llamar a Juan',
|
||||||
|
descripcion: 'Sobre cotización',
|
||||||
|
usuarioId,
|
||||||
|
leadCapturaId: null,
|
||||||
|
fechaVencimiento: new Date('2026-09-10T10:00:00'),
|
||||||
|
tipo: 'TAREA',
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,13 +102,17 @@ describe('RecordatorioVendedorService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('completar', () => {
|
describe('completar', () => {
|
||||||
it('marca como completado un recordatorio existente', async () => {
|
it('marca como completado un recordatorio existente y cierra su tarea del calendario', async () => {
|
||||||
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
|
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1', leadId: 'loc_1', usuarioId })
|
||||||
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
|
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
|
||||||
|
|
||||||
const result = await svc.completar('r1')
|
const result = await svc.completar('r1')
|
||||||
|
|
||||||
expect(result.completado).toBe(true)
|
expect(result.completado).toBe(true)
|
||||||
|
expect(prismaMock.actividadLocal.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { leadCapturaId: 'loc_1', usuarioId, tipo: 'TAREA', estado: 'ABIERTA' },
|
||||||
|
data: { estado: 'CERRADA', fechaCierre: expect.any(Date) },
|
||||||
|
})
|
||||||
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
|
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'r1' },
|
where: { id: 'r1' },
|
||||||
data: { completado: true },
|
data: { completado: true },
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface OpcionesListarRecordatorio {
|
|||||||
|
|
||||||
export class RecordatorioVendedorService {
|
export class RecordatorioVendedorService {
|
||||||
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
|
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
|
||||||
return prisma.recordatorioVendedor.create({
|
const recordatorio = await prisma.recordatorioVendedor.create({
|
||||||
data: {
|
data: {
|
||||||
usuarioId,
|
usuarioId,
|
||||||
titulo: data.titulo.trim(),
|
titulo: data.titulo.trim(),
|
||||||
@@ -41,6 +41,20 @@ export class RecordatorioVendedorService {
|
|||||||
fechaRecordar: new Date(data.fechaRecordar),
|
fechaRecordar: new Date(data.fechaRecordar),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// La tarea también vive en el calendario del vendedor: se crea la
|
||||||
|
// ActividadLocal ligada al cliente para que aparezca en "Mis actividades"
|
||||||
|
// y en el calendario de Mi día.
|
||||||
|
await prisma.actividadLocal.create({
|
||||||
|
data: {
|
||||||
|
titulo: recordatorio.titulo,
|
||||||
|
descripcion: recordatorio.descripcion,
|
||||||
|
usuarioId,
|
||||||
|
leadCapturaId: recordatorio.leadId,
|
||||||
|
fechaVencimiento: recordatorio.fechaRecordar,
|
||||||
|
tipo: 'TAREA',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return recordatorio
|
||||||
}
|
}
|
||||||
|
|
||||||
async listar(usuarioId: string, opciones: OpcionesListarRecordatorio = {}) {
|
async listar(usuarioId: string, opciones: OpcionesListarRecordatorio = {}) {
|
||||||
@@ -87,6 +101,19 @@ export class RecordatorioVendedorService {
|
|||||||
async completar(id: string) {
|
async completar(id: string) {
|
||||||
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
|
||||||
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
|
||||||
|
// Cierra también la tarea del calendario (ActividadLocal abierta ligada al
|
||||||
|
// mismo cliente y vendedor) para que desaparezca de "Mis actividades".
|
||||||
|
if (existente.leadId !== null) {
|
||||||
|
await prisma.actividadLocal.updateMany({
|
||||||
|
where: {
|
||||||
|
leadCapturaId: existente.leadId,
|
||||||
|
usuarioId: existente.usuarioId,
|
||||||
|
tipo: 'TAREA',
|
||||||
|
estado: 'ABIERTA',
|
||||||
|
},
|
||||||
|
data: { estado: 'CERRADA', fechaCierre: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
return prisma.recordatorioVendedor.update({
|
return prisma.recordatorioVendedor.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { completado: true },
|
data: { completado: true },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
actualizarUnidad,
|
||||||
crearUnidades,
|
crearUnidades,
|
||||||
eliminarUnidad,
|
eliminarUnidad,
|
||||||
estatusDe,
|
estatusDe,
|
||||||
@@ -147,6 +148,76 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
|
|||||||
|
|
||||||
const modelos = desarrollo.modelos ?? []
|
const modelos = desarrollo.modelos ?? []
|
||||||
|
|
||||||
|
// Edición de unidad existente (precios, m², ubicación, modelo y estatus)
|
||||||
|
const [editando, setEditando] = useState<UnidadDesarrollo | null>(null)
|
||||||
|
const [formEdicion, setFormEdicion] = useState({
|
||||||
|
precioLista: '',
|
||||||
|
precioAcabados: '',
|
||||||
|
supConstruccion: '',
|
||||||
|
supExcedente: '',
|
||||||
|
supEstacionamiento: '',
|
||||||
|
manzana: '',
|
||||||
|
lote: '',
|
||||||
|
nivel: '',
|
||||||
|
modeloId: '',
|
||||||
|
status: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const abrirEdicion = (unidad: UnidadDesarrollo) => {
|
||||||
|
setEditando(unidad)
|
||||||
|
setFormEdicion({
|
||||||
|
precioLista: String(unidad.precioSinAcabados ?? unidad.precioLista ?? ''),
|
||||||
|
precioAcabados: String(unidad.precioConAcabados ?? ''),
|
||||||
|
supConstruccion: String(unidad.supConstruccion ?? ''),
|
||||||
|
supExcedente: String(unidad.supExcedente ?? ''),
|
||||||
|
supEstacionamiento: String(unidad.supEstacionamiento ?? ''),
|
||||||
|
manzana: (unidad.extras?.['manzana'] as string | undefined) ?? '',
|
||||||
|
lote: (unidad.extras?.['lote'] as string | undefined) ?? '',
|
||||||
|
nivel: unidad.nivel ?? '',
|
||||||
|
modeloId: modelos.find((m) => m.nombre === unidad.modelo)?.id ?? '',
|
||||||
|
status: estatusDe(unidad) ?? '',
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
setExito('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cambiarEdicion = (campo: keyof typeof formEdicion) => (valor: string) =>
|
||||||
|
setFormEdicion((prev) => ({ ...prev, [campo]: valor }))
|
||||||
|
|
||||||
|
const guardarEdicion = async () => {
|
||||||
|
if (!editando) return
|
||||||
|
setError('')
|
||||||
|
setExito('')
|
||||||
|
const lista = aNumero(formEdicion.precioLista)
|
||||||
|
if (lista === null) {
|
||||||
|
setError('El precio de lista es obligatorio.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEnviando(true)
|
||||||
|
try {
|
||||||
|
await actualizarUnidad(desarrollo.id, editando.codigo, {
|
||||||
|
precioLista: lista,
|
||||||
|
...(aNumero(formEdicion.precioAcabados) !== null ? { precioAcabados: aNumero(formEdicion.precioAcabados)! } : {}),
|
||||||
|
...(aNumero(formEdicion.supConstruccion) !== null ? { supConstruccion: aNumero(formEdicion.supConstruccion)! } : {}),
|
||||||
|
...(aNumero(formEdicion.supExcedente) !== null ? { supExcedente: aNumero(formEdicion.supExcedente)! } : {}),
|
||||||
|
...(aNumero(formEdicion.supEstacionamiento) !== null ? { supEstacionamiento: aNumero(formEdicion.supEstacionamiento)! } : {}),
|
||||||
|
manzana: formEdicion.manzana.trim(),
|
||||||
|
lote: formEdicion.lote.trim(),
|
||||||
|
nivel: formEdicion.nivel.trim(),
|
||||||
|
...(formEdicion.modeloId !== '' ? { modeloId: formEdicion.modeloId } : {}),
|
||||||
|
...(formEdicion.status !== '' ? { status: formEdicion.status } : {}),
|
||||||
|
})
|
||||||
|
setExito(`Unidad ${editando.codigo} actualizada.`)
|
||||||
|
setEditando(null)
|
||||||
|
await cargarUnidades()
|
||||||
|
await onRefrescar()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error && err.message ? err.message : 'No se pudo actualizar la unidad.')
|
||||||
|
} finally {
|
||||||
|
setEnviando(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const guardarGenerador = async () => {
|
const guardarGenerador = async () => {
|
||||||
setError('')
|
setError('')
|
||||||
setExito('')
|
setExito('')
|
||||||
@@ -711,6 +782,14 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<BadgeEstatus estatus={estatusDe(unidad)} />
|
<BadgeEstatus estatus={estatusDe(unidad)} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => abrirEdicion(unidad)}
|
||||||
|
aria-label={`Editar unidad ${unidad.codigo}`}
|
||||||
|
className={BOTON_SECUNDARIO}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void borrarUnidad(unidad)}
|
onClick={() => void borrarUnidad(unidad)}
|
||||||
@@ -756,6 +835,113 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Modal de edición de unidad */}
|
||||||
|
{editando && (
|
||||||
|
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
|
||||||
|
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<h3 className="text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
Editar unidad <span className="font-mono">{editando.codigo}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio de lista *</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
value={formEdicion.precioLista}
|
||||||
|
onChange={(e) => cambiarEdicion('precioLista')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio con acabados</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
value={formEdicion.precioAcabados}
|
||||||
|
onChange={(e) => cambiarEdicion('precioAcabados')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. construcción (m²)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={formEdicion.supConstruccion}
|
||||||
|
onChange={(e) => cambiarEdicion('supConstruccion')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. excedente (m²)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={formEdicion.supExcedente}
|
||||||
|
onChange={(e) => cambiarEdicion('supExcedente')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. estacionamiento (m²)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={formEdicion.supEstacionamiento}
|
||||||
|
onChange={(e) => cambiarEdicion('supEstacionamiento')(e.target.value)}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Modelo</span>
|
||||||
|
<select value={formEdicion.modeloId} onChange={(e) => cambiarEdicion('modeloId')(e.target.value)} className={INPUT}>
|
||||||
|
<option value="">Sin modelo</option>
|
||||||
|
{modelos.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.nombre}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Manzana</span>
|
||||||
|
<input value={formEdicion.manzana} onChange={(e) => cambiarEdicion('manzana')(e.target.value)} className={INPUT} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Lote</span>
|
||||||
|
<input value={formEdicion.lote} onChange={(e) => cambiarEdicion('lote')(e.target.value)} className={INPUT} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Nivel</span>
|
||||||
|
<input value={formEdicion.nivel} onChange={(e) => cambiarEdicion('nivel')(e.target.value)} className={INPUT} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Estatus</span>
|
||||||
|
<select value={formEdicion.status} onChange={(e) => cambiarEdicion('status')(e.target.value)} className={INPUT}>
|
||||||
|
<option value="">Sin cambio</option>
|
||||||
|
{ESTATUS_UNIDAD.map((estatus) => (
|
||||||
|
<option key={estatus} value={estatus}>{estatus}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setEditando(null)} disabled={enviando} className={BOTON_SECUNDARIO}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void guardarEdicion()} disabled={enviando} className={BOTON_PRIMARIO}>
|
||||||
|
{enviando ? 'Guardando…' : 'Guardar cambios'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,3 +382,29 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cambios editables de una unidad (PATCH /desarrollos/:id/unidades/:codigo) */
|
||||||
|
export interface CambiosUnidad {
|
||||||
|
manzana?: string
|
||||||
|
lote?: string
|
||||||
|
nivel?: string
|
||||||
|
modeloId?: string
|
||||||
|
precioLista?: number
|
||||||
|
precioAcabados?: number
|
||||||
|
supConstruccion?: number
|
||||||
|
supExcedente?: number
|
||||||
|
supEstacionamiento?: number
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m², modelo y estatus */
|
||||||
|
export async function actualizarUnidad(
|
||||||
|
desarrolloId: string,
|
||||||
|
codigo: string,
|
||||||
|
cambios: CambiosUnidad,
|
||||||
|
): Promise<UnidadDesarrollo> {
|
||||||
|
return pedir<UnidadDesarrollo>(
|
||||||
|
`${rutaDesarrollo(desarrolloId)}/unidades/${encodeURIComponent(codigo)}`,
|
||||||
|
initJson('PATCH', cambios),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -209,6 +209,9 @@ export default function DetalleLeadModal({
|
|||||||
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
|
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
|
||||||
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
|
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
|
||||||
<InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
|
<InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
|
||||||
|
{'creadoEn' in item && item.creadoEn ? (
|
||||||
|
<InfoCampo etiqueta="Fecha de alta" valor={formatoFechaHora(item.creadoEn).slice(0, 10)} />
|
||||||
|
) : null}
|
||||||
{esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
|
{esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
|
||||||
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />}
|
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />}
|
||||||
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />}
|
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface KanbanViewProps<T extends { id: string; nombre: string; estado?: strin
|
|||||||
obtenerColumna: (item: T) => string
|
obtenerColumna: (item: T) => string
|
||||||
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
|
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
|
||||||
renderCard?: (item: T) => React.ReactNode
|
renderCard?: (item: T) => React.ReactNode
|
||||||
|
/** Click en la tarjeta: abre la bitácora/detalle del registro */
|
||||||
|
onVerItem?: (item: T) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
|
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
|
||||||
@@ -21,6 +23,7 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
|
|||||||
obtenerColumna,
|
obtenerColumna,
|
||||||
onCambiarEstado,
|
onCambiarEstado,
|
||||||
renderCard,
|
renderCard,
|
||||||
|
onVerItem,
|
||||||
}: KanbanViewProps<T>) {
|
}: KanbanViewProps<T>) {
|
||||||
const [arrastrando, setArrastrando] = useState<string | null>(null)
|
const [arrastrando, setArrastrando] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -70,7 +73,11 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => void onDragStart(e, item.id)}
|
onDragStart={(e) => void onDragStart(e, item.id)}
|
||||||
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''}`}
|
onClick={onVerItem ? () => onVerItem(item) : undefined}
|
||||||
|
role={onVerItem ? 'button' : undefined}
|
||||||
|
tabIndex={onVerItem ? 0 : undefined}
|
||||||
|
onKeyDown={onVerItem ? (e) => { if (e.key === 'Enter') onVerItem(item) } : undefined}
|
||||||
|
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''} ${onVerItem ? 'cursor-pointer hover:border-accent' : ''}`}
|
||||||
>
|
>
|
||||||
{renderCard ? renderCard(item) : (
|
{renderCard ? renderCard(item) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const apartadoFake: ApartadoMiDia = {
|
|||||||
unidadCodigo: 'D-101',
|
unidadCodigo: 'D-101',
|
||||||
estado: 'En proceso',
|
estado: 'En proceso',
|
||||||
fechaLimite: '2026-09-15',
|
fechaLimite: '2026-09-15',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ describe('urlCotizadorParaOportunidad', () => {
|
|||||||
unidadCodigo: 'U-9',
|
unidadCodigo: 'U-9',
|
||||||
origen: 'FACEBOOK',
|
origen: 'FACEBOOK',
|
||||||
updatedAt: '2026-09-04T10:00:00Z',
|
updatedAt: '2026-09-04T10:00:00Z',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +127,7 @@ describe('urlAfluenciaParaOportunidad', () => {
|
|||||||
unidadCodigo: '',
|
unidadCodigo: '',
|
||||||
origen: '',
|
origen: '',
|
||||||
updatedAt: '2026-09-04T10:00:00Z',
|
updatedAt: '2026-09-04T10:00:00Z',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface LeadMiDia {
|
|||||||
/** Vendedor asignado (nombre); '' si no tiene */
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
vendedor: string
|
vendedor: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
|
||||||
fuente: 'lead' | 'oportunidad'
|
fuente: 'lead' | 'oportunidad'
|
||||||
/** Último cambio conocido; null si no hay */
|
/** Último cambio conocido; null si no hay */
|
||||||
@@ -71,6 +73,8 @@ export interface OportunidadMiDia {
|
|||||||
/** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */
|
/** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */
|
||||||
origen: string
|
origen: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
|
||||||
fechaCita?: string
|
fechaCita?: string
|
||||||
@@ -91,9 +95,31 @@ export interface ApartadoMiDia {
|
|||||||
unidadCodigo: string
|
unidadCodigo: string
|
||||||
estado: string
|
estado: string
|
||||||
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registro descartado/abandonado (tab "Descartados" de Mi día, solo admin):
|
||||||
|
* leads descartados, oportunidades perdidas/abandonadas y apartados
|
||||||
|
* cancelados. Reasignable a cualquier vendedor.
|
||||||
|
*/
|
||||||
|
export interface DescartadoMiDia {
|
||||||
|
id: string
|
||||||
|
nombre: string
|
||||||
|
telefono: string
|
||||||
|
/** De dónde viene: lead | oportunidad | apartado */
|
||||||
|
fuente: 'lead' | 'oportunidad' | 'apartado'
|
||||||
|
etapa: string
|
||||||
|
motivo: string
|
||||||
|
/** Vendedor asignado (nombre); '' si no tiene */
|
||||||
|
vendedor: string
|
||||||
|
/** Fecha de alta (ISO) */
|
||||||
|
creadoEn: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumenMiDia {
|
export interface ResumenMiDia {
|
||||||
generadoEn: string
|
generadoEn: string
|
||||||
misLeads: LeadMiDia[]
|
misLeads: LeadMiDia[]
|
||||||
@@ -106,6 +132,8 @@ export interface ResumenMiDia {
|
|||||||
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
|
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
|
||||||
misApartados: ApartadoMiDia[]
|
misApartados: ApartadoMiDia[]
|
||||||
todosApartados: ApartadoMiDia[]
|
todosApartados: ApartadoMiDia[]
|
||||||
|
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
|
||||||
|
descartados: DescartadoMiDia[]
|
||||||
conteos: {
|
conteos: {
|
||||||
leadsNuevosSemana: number
|
leadsNuevosSemana: number
|
||||||
actividadesAbiertas: number
|
actividadesAbiertas: number
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
urlWhatsApp,
|
urlWhatsApp,
|
||||||
type ApartadoMiDia,
|
type ApartadoMiDia,
|
||||||
type ClaveLada,
|
type ClaveLada,
|
||||||
|
type DescartadoMiDia,
|
||||||
type FaseApartado,
|
type FaseApartado,
|
||||||
type LeadMiDia,
|
type LeadMiDia,
|
||||||
type MotivoNoCompra,
|
type MotivoNoCompra,
|
||||||
@@ -199,6 +200,9 @@ function FilaOportunidad({
|
|||||||
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
|
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
||||||
|
Alta: {formatoFechaCorta(op.creadoEn.slice(0, 10))}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
{op.etapa || 'Sin etapa'}
|
{op.etapa || 'Sin etapa'}
|
||||||
@@ -266,7 +270,16 @@ function FilaOportunidad({
|
|||||||
>
|
>
|
||||||
Aparto
|
Aparto
|
||||||
</button>
|
</button>
|
||||||
{/* 5. Descartar */}
|
{/* 5. No asistió: el cliente agendó y no llegó (se reagenda desde Confirmar cita) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={enviando}
|
||||||
|
onClick={() => onEtapa('No Asistio')}
|
||||||
|
className="rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium text-amber-700 shadow-sm transition-all duration-100 ease-out hover:bg-amber-50 disabled:opacity-50 dark:bg-[#0F172A] dark:border-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
No asistió
|
||||||
|
</button>
|
||||||
|
{/* 6. Descartar */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
|
disabled={enviando || (acciones !== undefined && !acciones.includes('descartar_lead'))}
|
||||||
@@ -416,6 +429,9 @@ function FilaApartado({
|
|||||||
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
|
{formatoFechaCorta(apartado.ultimoCambio.fechaISO.slice(0, 10))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
||||||
|
Alta: {formatoFechaCorta(apartado.creadoEn.slice(0, 10))}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
{apartado.estado || 'Sin estado'}
|
{apartado.estado || 'Sin estado'}
|
||||||
@@ -668,8 +684,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero / Calendario */
|
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Escrituración / Descartados (admin) / Mi tablero / Calendario */
|
||||||
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
|
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'descartados' | 'tablero' | 'calendario'
|
||||||
|
|
||||||
function TabsCrm({
|
function TabsCrm({
|
||||||
activa,
|
activa,
|
||||||
@@ -677,18 +693,22 @@ function TabsCrm({
|
|||||||
onCambiar,
|
onCambiar,
|
||||||
vistaKanban,
|
vistaKanban,
|
||||||
onToggleKanban,
|
onToggleKanban,
|
||||||
|
esAdmin,
|
||||||
}: {
|
}: {
|
||||||
activa: TabCrm
|
activa: TabCrm
|
||||||
conteos: Record<TabCrm, number>
|
conteos: Record<TabCrm, number>
|
||||||
onCambiar: (tab: TabCrm) => void
|
onCambiar: (tab: TabCrm) => void
|
||||||
vistaKanban: boolean
|
vistaKanban: boolean
|
||||||
onToggleKanban: () => void
|
onToggleKanban: () => void
|
||||||
|
/** Solo admin ve la pestaña "Descartados" */
|
||||||
|
esAdmin: boolean
|
||||||
}) {
|
}) {
|
||||||
const tabs: { id: TabCrm; titulo: string }[] = [
|
const tabs: { id: TabCrm; titulo: string }[] = [
|
||||||
{ id: 'leads', titulo: 'Leads' },
|
{ id: 'leads', titulo: 'Leads' },
|
||||||
{ id: 'oportunidades', titulo: 'Oportunidades' },
|
{ id: 'oportunidades', titulo: 'Oportunidades' },
|
||||||
{ id: 'apartados', titulo: 'Apartados' },
|
{ id: 'apartados', titulo: 'Apartados' },
|
||||||
{ id: 'escrituracion', titulo: 'Escrituración' },
|
{ id: 'escrituracion', titulo: 'Escrituración' },
|
||||||
|
...(esAdmin ? [{ id: 'descartados' as TabCrm, titulo: 'Descartados' }] : []),
|
||||||
{ id: 'tablero', titulo: 'Tablero' },
|
{ id: 'tablero', titulo: 'Tablero' },
|
||||||
{ id: 'calendario', titulo: 'Calendario' },
|
{ id: 'calendario', titulo: 'Calendario' },
|
||||||
]
|
]
|
||||||
@@ -716,7 +736,7 @@ function TabsCrm({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{activa !== 'tablero' && (
|
{activa !== 'tablero' && activa !== 'descartados' && activa !== 'calendario' && (
|
||||||
<div className="ml-auto flex gap-1">
|
<div className="ml-auto flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -794,6 +814,9 @@ function FilaLead({
|
|||||||
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
|
{formatoFechaCorta(lead.ultimoCambio.fechaISO.slice(0, 10))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
||||||
|
Alta: {formatoFechaCorta(lead.creadoEn.slice(0, 10))}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
||||||
{lead.estado || 'Sin estado'}
|
{lead.estado || 'Sin estado'}
|
||||||
@@ -878,6 +901,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
oportunidades: FILTROS_VACIOS,
|
oportunidades: FILTROS_VACIOS,
|
||||||
apartados: FILTROS_VACIOS,
|
apartados: FILTROS_VACIOS,
|
||||||
escrituracion: FILTROS_VACIOS,
|
escrituracion: FILTROS_VACIOS,
|
||||||
|
descartados: FILTROS_VACIOS,
|
||||||
tablero: FILTROS_VACIOS,
|
tablero: FILTROS_VACIOS,
|
||||||
calendario: FILTROS_VACIOS,
|
calendario: FILTROS_VACIOS,
|
||||||
})
|
})
|
||||||
@@ -1109,6 +1133,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
}, 'No se pudo reasignar el lead')
|
}, 'No se pudo reasignar el lead')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reasignar un descartado/abandonado a otro vendedor (tab Descartados, admin) */
|
||||||
|
async function reasignarDescartado(item: DescartadoMiDia, usuarioId: string) {
|
||||||
|
await ejecutar(async () => {
|
||||||
|
const nombre = await reasignarLeadApi(item.id, usuarioId)
|
||||||
|
toast.success(`Reasignado a ${nombre || 'vendedor'}`)
|
||||||
|
}, 'No se pudo reasignar el registro')
|
||||||
|
}
|
||||||
|
|
||||||
/** Mover lead entre tableros Leads ↔ Oportunidades (solo admin) */
|
/** Mover lead entre tableros Leads ↔ Oportunidades (solo admin) */
|
||||||
async function mover(lead: LeadMiDia) {
|
async function mover(lead: LeadMiDia) {
|
||||||
const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS'
|
const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS'
|
||||||
@@ -1366,15 +1398,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
conteos={{
|
conteos={{
|
||||||
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').length,
|
||||||
oportunidades: resumen.misOportunidades.items.length,
|
oportunidades: resumen.misOportunidades.items.length,
|
||||||
apartados: resumen.misApartados.length, escrituracion: escrituracionFiltrados.length, tablero: 0, calendario: 0,
|
apartados: resumen.misApartados.length, escrituracion: escrituracionFiltrados.length, descartados: resumen.descartados?.length ?? 0, tablero: 0, calendario: 0,
|
||||||
}}
|
}}
|
||||||
onCambiar={setTabCrm}
|
onCambiar={setTabCrm}
|
||||||
vistaKanban={vistaKanban}
|
vistaKanban={vistaKanban}
|
||||||
onToggleKanban={() => setVistaKanban(!vistaKanban)}
|
onToggleKanban={() => setVistaKanban(!vistaKanban)}
|
||||||
|
esAdmin={esAdmin}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{tabCrm !== 'tablero' && (
|
{tabCrm !== 'tablero' && tabCrm !== 'descartados' && tabCrm !== 'calendario' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
|
<FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
|
||||||
@@ -1416,6 +1448,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
items={leadsFiltrados}
|
items={leadsFiltrados}
|
||||||
columnas={columnasLeads}
|
columnas={columnasLeads}
|
||||||
obtenerColumna={obtenerColumnaLead}
|
obtenerColumna={obtenerColumnaLead}
|
||||||
|
onVerItem={(item) => setLeadSeleccionado(item)}
|
||||||
onCambiarEstado={(id, estado) => {
|
onCambiarEstado={(id, estado) => {
|
||||||
if (estado === 'DESCARTADO') {
|
if (estado === 'DESCARTADO') {
|
||||||
const lead = resumen.misLeads.find((l) => l.id === id)
|
const lead = resumen.misLeads.find((l) => l.id === id)
|
||||||
@@ -1458,6 +1491,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
items={opsFiltradas}
|
items={opsFiltradas}
|
||||||
columnas={columnasOportunidades}
|
columnas={columnasOportunidades}
|
||||||
obtenerColumna={obtenerColumnaOp}
|
obtenerColumna={obtenerColumnaOp}
|
||||||
|
onVerItem={(item) => setLeadSeleccionado(item)}
|
||||||
onCambiarEstado={(id, estado) => {
|
onCambiarEstado={(id, estado) => {
|
||||||
if (estado === 'PERDIDO') {
|
if (estado === 'PERDIDO') {
|
||||||
const op = resumen.misOportunidades.items.find((o) => o.id === id)
|
const op = resumen.misOportunidades.items.find((o) => o.id === id)
|
||||||
@@ -1512,6 +1546,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
items={apartadosFiltrados}
|
items={apartadosFiltrados}
|
||||||
columnas={columnasApartados}
|
columnas={columnasApartados}
|
||||||
obtenerColumna={obtenerColumnaApartado}
|
obtenerColumna={obtenerColumnaApartado}
|
||||||
|
onVerItem={(item) => setLeadSeleccionado(item)}
|
||||||
onCambiarEstado={(id, estado) => {
|
onCambiarEstado={(id, estado) => {
|
||||||
if (estado === 'Cancelacion Apartado') {
|
if (estado === 'Cancelacion Apartado') {
|
||||||
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
|
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
|
||||||
@@ -1569,6 +1604,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
items={escrituracionFiltrados}
|
items={escrituracionFiltrados}
|
||||||
columnas={columnasEscrituracion}
|
columnas={columnasEscrituracion}
|
||||||
obtenerColumna={obtenerColumnaEscrituracion}
|
obtenerColumna={obtenerColumnaEscrituracion}
|
||||||
|
onVerItem={(item) => setLeadSeleccionado(item)}
|
||||||
onCambiarEstado={(id, estado) => {
|
onCambiarEstado={(id, estado) => {
|
||||||
if (estado === 'Cancelo') {
|
if (estado === 'Cancelo') {
|
||||||
const a = escrituracionFiltrados.find((x) => x.id === id)
|
const a = escrituracionFiltrados.find((x) => x.id === id)
|
||||||
@@ -1591,7 +1627,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
>
|
>
|
||||||
{apartado.nombre}
|
{apartado.nombre}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}</p>
|
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado} · Alta:{' '}
|
||||||
|
{formatoFechaCorta(apartado.creadoEn.slice(0, 10))}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
@@ -1624,6 +1663,60 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tabCrm === 'descartados' && (
|
||||||
|
<section aria-label="Descartados">
|
||||||
|
<TituloSeccion>Descartados y abandonados</TituloSeccion>
|
||||||
|
<p className="mb-4 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
Leads, oportunidades y apartados descartados o abandonados. Puedes reasignarlos a cualquier vendedor.
|
||||||
|
</p>
|
||||||
|
{(resumen.descartados ?? []).length === 0 ? (
|
||||||
|
<EstadoVacio mensaje="No hay registros descartados" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{(resumen.descartados ?? []).map((d) => (
|
||||||
|
<li key={d.id} className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="flex items-center gap-2 text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
|
{d.nombre}
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-[#334155] dark:text-[#CBD5E1]">
|
||||||
|
{d.fuente === 'lead' ? 'Lead' : d.fuente === 'oportunidad' ? 'Oportunidad' : 'Apartado'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||||
|
{d.telefono || 'Sin teléfono'}
|
||||||
|
{d.etapa ? ` · ${d.etapa}` : ''}
|
||||||
|
{d.motivo ? ` · Motivo: ${d.motivo}` : ''}
|
||||||
|
{d.vendedor ? ` · ${d.vendedor}` : ''}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
|
||||||
|
Alta: {formatoFechaCorta(d.creadoEn.slice(0, 10))} · Último cambio:{' '}
|
||||||
|
{formatoFechaCorta(d.updatedAt.slice(0, 10))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
disabled={enviando}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value !== '') void reasignarDescartado(d, e.target.value)
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#374151] transition-colors focus:border-accent focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#CBD5E1]"
|
||||||
|
aria-label={`Reasignar ${d.nombre} a vendedor`}
|
||||||
|
>
|
||||||
|
<option value="">Reasignar a…</option>
|
||||||
|
{(resumen.equipo?.porVendedor ?? []).map((v) => (
|
||||||
|
<option key={v.usuarioId} value={v.usuarioId}>{v.nombre}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{tabCrm === 'tablero' && (
|
{tabCrm === 'tablero' && (
|
||||||
<DashboardVendedor />
|
<DashboardVendedor />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const leadFake: LeadMiDia = {
|
|||||||
origen: 'PASE',
|
origen: 'PASE',
|
||||||
vendedor: 'Martin Zavala',
|
vendedor: 'Martin Zavala',
|
||||||
updatedAt: '2026-09-01T10:00:00Z',
|
updatedAt: '2026-09-01T10:00:00Z',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
fuente: 'lead',
|
fuente: 'lead',
|
||||||
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
|
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,7 @@ const opFake: OportunidadMiDia = {
|
|||||||
unidadCodigo: 'D-101',
|
unidadCodigo: 'D-101',
|
||||||
origen: '',
|
origen: '',
|
||||||
updatedAt: '2026-09-02T12:00:00Z',
|
updatedAt: '2026-09-02T12:00:00Z',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
|
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ const apartadoFake: ApartadoMiDia = {
|
|||||||
unidadCodigo: 'E-202',
|
unidadCodigo: 'E-202',
|
||||||
estado: 'En proceso',
|
estado: 'En proceso',
|
||||||
fechaLimite: '2026-09-15',
|
fechaLimite: '2026-09-15',
|
||||||
|
creadoEn: '2026-09-01T09:00:00Z',
|
||||||
ultimoCambio: null,
|
ultimoCambio: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user