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) 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 */

View File

@@ -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)

View File

@@ -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) ────────────────────────────────────
/** /**

View File

@@ -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,

View File

@@ -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 },

View File

@@ -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 },

View File

@@ -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>
) )
} }

View File

@@ -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),
)
}

View File

@@ -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)} />}

View File

@@ -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) : (
<> <>

View File

@@ -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,
} }

View File

@@ -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,
} }

View File

@@ -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

View File

@@ -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 />
)} )}

View File

@@ -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,
} }