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)
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user