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 && ( +
+
+

+ Editar unidad {editando.codigo} +

+
+ + + + + + + + + + +
+ {error &&
{error}
} +
+ + +
+
+
+ )} ) } 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, }