From 2400ecf6ff293266640c4916c903ca76d6d4073e Mon Sep 17 00:00:00 2001
From: Consult AS Technology
Date: Fri, 25 Sep 2026 18:28:14 +0000
Subject: [PATCH] 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
---
.../src/controllers/desarrollos.controller.ts | 10 +
api-gateway/src/routes/index.ts | 2 +
.../src/services/desarrollos.service.ts | 51 +++++
api-gateway/src/services/mi-dia.service.ts | 57 ++++++
.../recordatorio-vendedor.service.test.ts | 23 ++-
.../services/recordatorio-vendedor.service.ts | 29 ++-
.../components/desarrollos/TabInventario.tsx | 186 ++++++++++++++++++
apps/landing/src/services/desarrollos.ts | 26 +++
.../src/components/DetalleLeadModal.tsx | 3 +
apps/mi-dia/src/components/KanbanView.tsx | 9 +-
.../src/components/NotasApartado.test.tsx | 1 +
apps/mi-dia/src/lib/mi-dia.test.ts | 2 +
apps/mi-dia/src/lib/mi-dia.ts | 28 +++
apps/mi-dia/src/pages/MiDiaPage.tsx | 109 +++++++++-
apps/mi-dia/src/services/exportar.test.ts | 3 +
15 files changed, 527 insertions(+), 12 deletions(-)
diff --git a/api-gateway/src/controllers/desarrollos.controller.ts b/api-gateway/src/controllers/desarrollos.controller.ts
index 66f25ca..eede9ef 100644
--- a/api-gateway/src/controllers/desarrollos.controller.ts
+++ b/api-gateway/src/controllers/desarrollos.controller.ts
@@ -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 {
+ 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 */
diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts
index 9ca3b59..41aeec5 100644
--- a/api-gateway/src/routes/index.ts
+++ b/api-gateway/src/routes/index.ts
@@ -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)
diff --git a/api-gateway/src/services/desarrollos.service.ts b/api-gateway/src/services/desarrollos.service.ts
index 719e1dd..67d80e5 100644
--- a/api-gateway/src/services/desarrollos.service.ts
+++ b/api-gateway/src/services/desarrollos.service.ts
@@ -168,6 +168,24 @@ export const crearUnidadesSchema = z
export type CrearUnidadesBody = z.infer
+/** 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
+
// ── 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 = {}
+ 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) ────────────────────────────────────
/**
diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts
index 233fd67..e1c72cf 100644
--- a/api-gateway/src/services/mi-dia.service.ts
+++ b/api-gateway/src/services/mi-dia.service.ts
@@ -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,
diff --git a/api-gateway/src/services/recordatorio-vendedor.service.test.ts b/api-gateway/src/services/recordatorio-vendedor.service.test.ts
index 76bf4d3..e688774 100644
--- a/api-gateway/src/services/recordatorio-vendedor.service.test.ts
+++ b/api-gateway/src/services/recordatorio-vendedor.service.test.ts
@@ -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 },
diff --git a/api-gateway/src/services/recordatorio-vendedor.service.ts b/api-gateway/src/services/recordatorio-vendedor.service.ts
index 1fef54d..24a852b 100644
--- a/api-gateway/src/services/recordatorio-vendedor.service.ts
+++ b/api-gateway/src/services/recordatorio-vendedor.service.ts
@@ -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 },
diff --git a/apps/landing/src/components/desarrollos/TabInventario.tsx b/apps/landing/src/components/desarrollos/TabInventario.tsx
index a14adef..5bc1a33 100644
--- a/apps/landing/src/components/desarrollos/TabInventario.tsx
+++ b/apps/landing/src/components/desarrollos/TabInventario.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
+ actualizarUnidad,
crearUnidades,
eliminarUnidad,
estatusDe,
@@ -147,6 +148,76 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
const modelos = desarrollo.modelos ?? []
+ // Edición de unidad existente (precios, m², ubicación, modelo y estatus)
+ const [editando, setEditando] = useState(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 () => {
setError('')
setExito('')
@@ -711,6 +782,14 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
+
)}
+
+ {/* Modal de edición de unidad */}
+ {editando && (
+
+ )}
)
}
diff --git a/apps/landing/src/services/desarrollos.ts b/apps/landing/src/services/desarrollos.ts
index 2092f4e..27c72e6 100644
--- a/apps/landing/src/services/desarrollos.ts
+++ b/apps/landing/src/services/desarrollos.ts
@@ -382,3 +382,29 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
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 {
+ return pedir(
+ `${rutaDesarrollo(desarrolloId)}/unidades/${encodeURIComponent(codigo)}`,
+ initJson('PATCH', cambios),
+ )
+}
diff --git a/apps/mi-dia/src/components/DetalleLeadModal.tsx b/apps/mi-dia/src/components/DetalleLeadModal.tsx
index 3892656..c695eca 100644
--- a/apps/mi-dia/src/components/DetalleLeadModal.tsx
+++ b/apps/mi-dia/src/components/DetalleLeadModal.tsx
@@ -209,6 +209,9 @@ export default function DetalleLeadModal({
{esOp && item.unidadCodigo && }
{esLd && item.origen && }
+ {'creadoEn' in item && item.creadoEn ? (
+
+ ) : null}
{esOp && item.fechaCita && }
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && }
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && }
diff --git a/apps/mi-dia/src/components/KanbanView.tsx b/apps/mi-dia/src/components/KanbanView.tsx
index 7e6759f..7ec3cba 100644
--- a/apps/mi-dia/src/components/KanbanView.tsx
+++ b/apps/mi-dia/src/components/KanbanView.tsx
@@ -13,6 +13,8 @@ interface KanbanViewProps string
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
renderCard?: (item: T) => React.ReactNode
+ /** Click en la tarjeta: abre la bitácora/detalle del registro */
+ onVerItem?: (item: T) => void
}
export default function KanbanView({
@@ -21,6 +23,7 @@ export default function KanbanView) {
const [arrastrando, setArrastrando] = useState(null)
@@ -70,7 +73,11 @@ export default function KanbanView 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) : (
<>
diff --git a/apps/mi-dia/src/components/NotasApartado.test.tsx b/apps/mi-dia/src/components/NotasApartado.test.tsx
index 866fc70..54eb3ef 100644
--- a/apps/mi-dia/src/components/NotasApartado.test.tsx
+++ b/apps/mi-dia/src/components/NotasApartado.test.tsx
@@ -34,6 +34,7 @@ const apartadoFake: ApartadoMiDia = {
unidadCodigo: 'D-101',
estado: 'En proceso',
fechaLimite: '2026-09-15',
+ creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}
diff --git a/apps/mi-dia/src/lib/mi-dia.test.ts b/apps/mi-dia/src/lib/mi-dia.test.ts
index 0462fa1..f714a73 100644
--- a/apps/mi-dia/src/lib/mi-dia.test.ts
+++ b/apps/mi-dia/src/lib/mi-dia.test.ts
@@ -85,6 +85,7 @@ describe('urlCotizadorParaOportunidad', () => {
unidadCodigo: 'U-9',
origen: 'FACEBOOK',
updatedAt: '2026-09-04T10:00:00Z',
+ creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}
@@ -126,6 +127,7 @@ describe('urlAfluenciaParaOportunidad', () => {
unidadCodigo: '',
origen: '',
updatedAt: '2026-09-04T10:00:00Z',
+ creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}
diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts
index cd6d7e6..d1e56a0 100644
--- a/apps/mi-dia/src/lib/mi-dia.ts
+++ b/apps/mi-dia/src/lib/mi-dia.ts
@@ -11,6 +11,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 */
@@ -71,6 +73,8 @@ export interface OportunidadMiDia {
/** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */
origen: string
updatedAt: string
+ /** Fecha de alta (ISO) */
+ creadoEn: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
fechaCita?: string
@@ -91,9 +95,31 @@ export interface ApartadoMiDia {
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
+ /** Fecha de alta (ISO) */
+ creadoEn: string
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[]
@@ -106,6 +132,8 @@ export interface ResumenMiDia {
misOportunidades: { porEtapa: Record; items: OportunidadMiDia[] } // conteo + lista de trabajo
misApartados: ApartadoMiDia[]
todosApartados: ApartadoMiDia[]
+ /** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
+ descartados: DescartadoMiDia[]
conteos: {
leadsNuevosSemana: number
actividadesAbiertas: number
diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx
index 00d80a4..5329d2b 100644
--- a/apps/mi-dia/src/pages/MiDiaPage.tsx
+++ b/apps/mi-dia/src/pages/MiDiaPage.tsx
@@ -28,6 +28,7 @@ import {
urlWhatsApp,
type ApartadoMiDia,
type ClaveLada,
+ type DescartadoMiDia,
type FaseApartado,
type LeadMiDia,
type MotivoNoCompra,
@@ -199,6 +200,9 @@ function FilaOportunidad({
{formatoFechaCorta(op.ultimoCambio.fechaISO.slice(0, 10))}
)}
+
+ Alta: {formatoFechaCorta(op.creadoEn.slice(0, 10))}
+
{op.etapa || 'Sin etapa'}
@@ -266,7 +270,16 @@ function FilaOportunidad({
>
Aparto
- {/* 5. Descartar */}
+ {/* 5. No asistió: el cliente agendó y no llegó (se reagenda desde Confirmar cita) */}
+
+ {/* 6. Descartar */}
)
})}
- {activa !== 'tablero' && (
+ {activa !== 'tablero' && activa !== 'descartados' && activa !== 'calendario' && (
{lead.estado || 'Sin estado'}
@@ -878,6 +901,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
oportunidades: FILTROS_VACIOS,
apartados: FILTROS_VACIOS,
escrituracion: FILTROS_VACIOS,
+ descartados: FILTROS_VACIOS,
tablero: FILTROS_VACIOS,
calendario: FILTROS_VACIOS,
})
@@ -1109,6 +1133,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
}, '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) */
async function mover(lead: LeadMiDia) {
const destino = lead.fuente === 'lead' ? 'OPORTUNIDADES' : 'LEADS'
@@ -1366,15 +1398,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
conteos={{
leads: resumen.misLeads.filter((l) => l.fuente === 'lead').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}
vistaKanban={vistaKanban}
onToggleKanban={() => setVistaKanban(!vistaKanban)}
-
+ esAdmin={esAdmin}
/>
- {tabCrm !== 'tablero' && (
+ {tabCrm !== 'tablero' && tabCrm !== 'descartados' && tabCrm !== 'calendario' && (
@@ -1416,6 +1448,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={leadsFiltrados}
columnas={columnasLeads}
obtenerColumna={obtenerColumnaLead}
+ onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'DESCARTADO') {
const lead = resumen.misLeads.find((l) => l.id === id)
@@ -1458,6 +1491,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={opsFiltradas}
columnas={columnasOportunidades}
obtenerColumna={obtenerColumnaOp}
+ onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'PERDIDO') {
const op = resumen.misOportunidades.items.find((o) => o.id === id)
@@ -1512,6 +1546,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={apartadosFiltrados}
columnas={columnasApartados}
obtenerColumna={obtenerColumnaApartado}
+ onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'Cancelacion Apartado') {
// Pedir motivo antes de cambiar (ModalDescarteGenerico)
@@ -1569,6 +1604,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={escrituracionFiltrados}
columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion}
+ onVerItem={(item) => setLeadSeleccionado(item)}
onCambiarEstado={(id, estado) => {
if (estado === 'Cancelo') {
const a = escrituracionFiltrados.find((x) => x.id === id)
@@ -1591,7 +1627,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
>
{apartado.nombre}
-
{apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado}
+
+ {apartado.unidadCodigo || 'Sin unidad'} · {apartado.estado} · Alta:{' '}
+ {formatoFechaCorta(apartado.creadoEn.slice(0, 10))}
+
@@ -1624,6 +1663,60 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
)}
+ {tabCrm === 'descartados' && (
+
+ Descartados y abandonados
+
+ Leads, oportunidades y apartados descartados o abandonados. Puedes reasignarlos a cualquier vendedor.
+
+ {(resumen.descartados ?? []).length === 0 ? (
+
+ ) : (
+
+ {(resumen.descartados ?? []).map((d) => (
+ -
+
+
+
+ {d.nombre}
+
+ {d.fuente === 'lead' ? 'Lead' : d.fuente === 'oportunidad' ? 'Oportunidad' : 'Apartado'}
+
+
+
+ {d.telefono || 'Sin teléfono'}
+ {d.etapa ? ` · ${d.etapa}` : ''}
+ {d.motivo ? ` · Motivo: ${d.motivo}` : ''}
+ {d.vendedor ? ` · ${d.vendedor}` : ''}
+
+
+ Alta: {formatoFechaCorta(d.creadoEn.slice(0, 10))} · Último cambio:{' '}
+ {formatoFechaCorta(d.updatedAt.slice(0, 10))}
+
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
{tabCrm === 'tablero' && (
)}
diff --git a/apps/mi-dia/src/services/exportar.test.ts b/apps/mi-dia/src/services/exportar.test.ts
index 5fb5d0d..3f2c752 100644
--- a/apps/mi-dia/src/services/exportar.test.ts
+++ b/apps/mi-dia/src/services/exportar.test.ts
@@ -35,6 +35,7 @@ const leadFake: LeadMiDia = {
origen: 'PASE',
vendedor: 'Martin Zavala',
updatedAt: '2026-09-01T10:00:00Z',
+ creadoEn: '2026-09-01T09:00:00Z',
fuente: 'lead',
ultimoCambio: { fechaISO: '2026-09-01T10:00:00Z', usuario: 'Admin', detalle: 'Lead creado' },
}
@@ -52,6 +53,7 @@ const opFake: OportunidadMiDia = {
unidadCodigo: 'D-101',
origen: '',
updatedAt: '2026-09-02T12:00:00Z',
+ creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: { fechaISO: '2026-09-02T12:00:00Z', usuario: 'Admin', detalle: 'Etapa cambiada' },
}
@@ -66,6 +68,7 @@ const apartadoFake: ApartadoMiDia = {
unidadCodigo: 'E-202',
estado: 'En proceso',
fechaLimite: '2026-09-15',
+ creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null,
}