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:
2026-09-25 18:28:14 +00:00
parent 7f663208a0
commit 2400ecf6ff
15 changed files with 527 additions and 12 deletions

View File

@@ -128,6 +128,16 @@ export async function eliminarUnidadEndpoint(req: Request, res: Response): Promi
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) ──────────────────────────────────────────────
/** GET /api/publico/desarrollos */

View File

@@ -168,6 +168,7 @@ import {
import {
actualizarDesarrolloEndpoint,
actualizarModeloEndpoint,
actualizarUnidadEndpoint,
agregarModeloEndpoint,
crearDesarrolloEndpoint,
crearUnidadesEndpoint,
@@ -409,4 +410,5 @@ apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEnd
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
apiRouter.patch('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, actualizarUnidadEndpoint)
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)

View File

@@ -168,6 +168,24 @@ export const crearUnidadesSchema = z
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 ───────────────────────────────────────────────────────────
interface ModeloFila {
@@ -635,6 +653,39 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
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) ────────────────────────────────────
/**

View File

@@ -75,6 +75,8 @@ export interface LeadMiDia {
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
/** Último cambio conocido; null si no hay */
@@ -131,6 +133,8 @@ export interface OportunidadMiDia {
modeloInteres: string
unidadCodigo: string
updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
/** Último cambio conocido; null si no hay */
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') */
@@ -152,10 +156,32 @@ export interface ApartadoMiDia {
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Fecha de alta (ISO) */
creadoEn: string
/** Último cambio conocido; null si no hay */
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 {
generadoEn: string
misLeads: LeadMiDia[]
@@ -171,6 +197,8 @@ export interface ResumenMiDia {
misApartados: ApartadoMiDia[]
/** Todos los apartados incluyendo terminales (para Escrituración) */
todosApartados: ApartadoMiDia[]
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
descartados: DescartadoMiDia[]
conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto
@@ -212,6 +240,8 @@ export interface FilaLocalMiDia {
usuarioId: string
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
mondayItemId: string | null
/** Motivo de descarte/cancelación (columna motivo de LeadCaptura) */
motivo: string | null
pendienteSync: boolean
createdAt: Date
updatedAt: Date
@@ -302,6 +332,7 @@ export function resumenMiDiaLocal(
desarrollo: c.desarrollo ?? '',
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
updatedAt: c.updatedAt.toISOString(),
creadoEn: c.createdAt.toISOString(),
fuente: 'lead' as const,
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
pendienteSync: c.pendienteSync,
@@ -341,6 +372,7 @@ export function resumenMiDiaLocal(
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
unidadCodigo,
updatedAt: c.updatedAt.toISOString(),
creadoEn: c.createdAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
fechaCita,
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
@@ -368,6 +400,7 @@ export function resumenMiDiaLocal(
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
creadoEn: c.createdAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
pendienteSync: c.pendienteSync,
}))
@@ -389,6 +422,7 @@ export function resumenMiDiaLocal(
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
creadoEn: c.createdAt.toISOString(),
ultimoCambio: null,
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 {
generadoEn: ahora.toISOString(),
misLeads,
@@ -550,6 +606,7 @@ export function resumenMiDiaLocal(
misApartados,
// Todos los apartados incluyendo terminales (para Escrituración)
todosApartados: todosApartadosLocal,
descartados,
conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs,

View File

@@ -8,6 +8,10 @@ const prismaMock = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
},
actividadLocal: {
create: vi.fn(),
updateMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -55,6 +59,17 @@ describe('RecordatorioVendedorService', () => {
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', () => {
it('marca como completado un recordatorio existente', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
it('marca como completado un recordatorio existente y cierra su tarea del calendario', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1', leadId: 'loc_1', usuarioId })
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
const result = await svc.completar('r1')
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({
where: { id: 'r1' },
data: { completado: true },

View File

@@ -31,7 +31,7 @@ export interface OpcionesListarRecordatorio {
export class RecordatorioVendedorService {
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
return prisma.recordatorioVendedor.create({
const recordatorio = await prisma.recordatorioVendedor.create({
data: {
usuarioId,
titulo: data.titulo.trim(),
@@ -41,6 +41,20 @@ export class RecordatorioVendedorService {
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 = {}) {
@@ -87,6 +101,19 @@ export class RecordatorioVendedorService {
async completar(id: string) {
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
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({
where: { id },
data: { completado: true },