feat(8-mejoras): cancelar+motivo, brocker, etapas, calendario, modal cliente

1. Cancelar con motivo: Mis apartados y Escrituración piden motivo (modal reutilizado)
2. Origen BROCKER agregado en alta afluencia y captura rápida
3. Etapas oportunidades reordenadas: Agendo Cita → Afluencia → Cotizador
4. Cotizador: mensualidades diferibles (6, 12, 14, 18, 24 meses)
5. Calendario semanal por vendedor en Mi día
6. Hora en citas (datetime-local en vez de solo fecha)
7. Click en nombre → modal DetalleLeadModal (info + notas + historial)
This commit is contained in:
2026-09-09 10:26:36 +00:00
parent 6db9662838
commit d546460ba4
11 changed files with 808 additions and 26 deletions

View File

@@ -0,0 +1,284 @@
import { useMemo, useState } from 'react'
import type { ActividadMiDia, OportunidadMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia'
/** Evento unificado para el calendario */
interface EventoCalendario {
id: string
nombre: string
fecha: string // 'YYYY-MM-DD'
hora?: string // 'HH:MM'
tipo: 'actividad' | 'oportunidad'
detalle?: string
estado?: string
idOriginal: string
}
interface Props {
actividades: ActividadMiDia[]
oportunidades: OportunidadMiDia[]
}
const NOMBRE_DIAS = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']
const NOMBRE_MESES = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
function obtenerSemanaBase(fecha: Date): Date {
const d = new Date(fecha)
const diaSemana = d.getDay()
// Lunes como inicio de semana
d.setDate(d.getDate() - ((diaSemana + 6) % 7))
d.setHours(0, 0, 0, 0)
return d
}
function formatoDia(fecha: Date): string {
return `${fecha.getDate()}`
}
function esMismaFecha(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function parsearFechaCita(fechaCita: string): { fecha: string; hora?: string } {
if (fechaCita.includes('T')) {
const [fecha, horaCompleta] = fechaCita.split('T')
const hora = horaCompleta?.slice(0, 5)
return { fecha, hora }
}
return { fecha: fechaCita }
}
export default function CalendarioCitas({ actividades, oportunidades }: Props) {
const hoy = useMemo(() => {
const d = new Date()
d.setHours(0, 0, 0, 0)
return d
}, [])
const [semanaInicio, setSemanaInicio] = useState(() => obtenerSemanaBase(hoy))
const eventos = useMemo<EventoCalendario[]>(() => {
const lista: EventoCalendario[] = []
// Agregar actividades
for (const a of actividades) {
if (a.fecha === '') continue
lista.push({
id: `act-${a.id}`,
nombre: a.nombre,
fecha: a.fecha,
tipo: 'actividad',
detalle: a.tipo,
estado: a.estado,
idOriginal: a.id,
})
}
// Agregar oportunidades con fechaCita
for (const op of oportunidades) {
if (!op.fechaCita) continue
const { fecha, hora } = parsearFechaCita(op.fechaCita)
lista.push({
id: `op-${op.id}`,
nombre: op.nombre,
fecha,
hora,
tipo: 'oportunidad',
detalle: op.etapa,
estado: op.etapa,
idOriginal: op.id,
})
}
return lista
}, [actividades, oportunidades])
// Generar los 7 días de la semana
const diasSemana = useMemo(() => {
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(semanaInicio)
d.setDate(d.getDate() + i)
return d
})
}, [semanaInicio])
// Eventos agrupados por día
const eventosPorDia = useMemo(() => {
const mapa = new Map<string, EventoCalendario[]>()
for (const ev of eventos) {
const clave = ev.fecha
if (!mapa.has(clave)) mapa.set(clave, [])
mapa.get(clave)!.push(ev)
}
return mapa
}, [eventos])
const semanaAnterior = () => {
const d = new Date(semanaInicio)
d.setDate(d.getDate() - 7)
setSemanaInicio(d)
}
const semanaSiguiente = () => {
const d = new Date(semanaInicio)
d.setDate(d.getDate() + 7)
setSemanaInicio(d)
}
const irAHoy = () => {
setSemanaInicio(obtenerSemanaBase(hoy))
}
const [eventoSeleccionado, setEventoSeleccionado] = useState<EventoCalendario | null>(null)
const semanaFin = new Date(semanaInicio)
semanaFin.setDate(semanaFin.getDate() + 6)
const tituloSemana = `${formatoDia(semanaInicio)} ${NOMBRE_MESES[semanaInicio.getMonth()]} – ${formatoDia(semanaFin)} ${NOMBRE_MESES[semanaFin.getMonth()]} ${semanaFin.getFullYear()}`
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
{/* Controles de navegación */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={semanaAnterior}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
←
</button>
<button
type="button"
onClick={irAHoy}
className="rounded-lg border border-accent bg-accent/10 px-3 py-1 text-xs font-medium text-accent shadow-sm transition-all hover:bg-accent hover:text-white"
>
Hoy
</button>
<button
type="button"
onClick={semanaSiguiente}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
→
</button>
</div>
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{tituloSemana}
</p>
</div>
{/* Grid del calendario */}
<div className="grid grid-cols-7 gap-1">
{/* Encabezados de días */}
{diasSemana.map((dia, idx) => {
const esHoy = esMismaFecha(dia, hoy)
return (
<div
key={idx}
className={`rounded-t-lg px-2 py-2 text-center ${
esHoy
? 'bg-accent text-white font-semibold'
: 'bg-[#F8FAFC] dark:bg-[#0F172A] text-[#64748B] dark:text-[#94A3B8] font-medium'
}`}
>
<p className="text-[10px] uppercase tracking-wider">{NOMBRE_DIAS[dia.getDay()]}</p>
<p className={`text-lg ${esHoy ? 'text-white' : 'text-[#111827] dark:text-[#E2E8F0]'}`}>{formatoDia(dia)}</p>
</div>
)
})}
{/* Celdas de eventos */}
{diasSemana.map((dia, idx) => {
const clave = `${dia.getFullYear()}-${String(dia.getMonth() + 1).padStart(2, '0')}-${String(dia.getDate()).padStart(2, '0')}`
const eventosDia = eventosPorDia.get(clave) ?? []
const esHoy = esMismaFecha(dia, hoy)
return (
<div
key={idx}
className={`min-h-[100px] rounded-b-lg border border-t-0 border-[#E5E7EB] dark:border-[#1E293B] p-1 ${
esHoy ? 'bg-accent/5 dark:bg-accent/10' : 'bg-white dark:bg-[#1E293B]'
}`}
>
{eventosDia.length === 0 ? (
<p className="mt-2 text-center text-[10px] text-[#CBD5E1] dark:text-[#475569]">—</p>
) : (
<div className="space-y-1">
{eventosDia.map((ev) => (
<button
key={ev.id}
type="button"
onClick={() => setEventoSeleccionado(ev)}
className={`w-full rounded px-1.5 py-1 text-left text-[10px] font-medium leading-tight transition-all hover:opacity-80 ${
ev.tipo === 'oportunidad'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
}`}
title={ev.detalle ? `${ev.nombre} (${ev.detalle})` : ev.nombre}
>
{ev.hora && <span className="font-semibold">{ev.hora} </span>}
{ev.nombre.length > 12 ? ev.nombre.slice(0, 12) + '…' : ev.nombre}
</button>
))}
</div>
)}
</div>
)
})}
</div>
{/* Leyenda */}
<div className="mt-3 flex items-center gap-4 text-[11px] text-[#64748B] dark:text-[#94A3B8]">
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-amber-400" /> Actividades
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-full bg-blue-400" /> Oportunidades
</span>
<span className="ml-auto">{eventos.length} evento{eventos.length !== 1 ? 's' : ''} esta semana</span>
</div>
{/* Modal de detalle */}
{eventoSeleccionado && (
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-sm rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{eventoSeleccionado.tipo === 'oportunidad' ? 'Oportunidad' : 'Actividad'}
</p>
<button
type="button"
onClick={() => setEventoSeleccionado(null)}
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
>
Cerrar
</button>
</div>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">
{eventoSeleccionado.nombre}
</p>
<div className="mt-3 space-y-2">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
📅 {formatoFechaCorta(eventoSeleccionado.fecha)}
{eventoSeleccionado.hora && ` a las ${eventoSeleccionado.hora}`}
</p>
{eventoSeleccionado.detalle && (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
📋 {eventoSeleccionado.detalle}
</p>
)}
{eventoSeleccionado.estado && (
<p className="text-sm">
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{eventoSeleccionado.estado}
</span>
</p>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia'
import TimelineLead, { type EventoLead } from './TimelineLead'
import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios'
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
function esLead(item: LeadItem): item is LeadMiDia {
return 'email' in item && 'origen' in item && !('modeloInteres' in item)
}
function esOportunidad(item: LeadItem): item is OportunidadMiDia {
return 'modeloInteres' in item
}
export default function DetalleLeadModal({
item,
onCerrar,
}: {
item: LeadItem
onCerrar: () => void
}) {
const esOp = esOportunidad(item)
const esLd = esLead(item)
// --- Historial ---
const eventos: EventoLead[] = item.ultimoCambio
? [{
fechaISO: item.ultimoCambio.fechaISO,
usuario: item.ultimoCambio.usuario,
detalle: item.ultimoCambio.detalle,
fuente: esLd ? item.fuente : esOp ? undefined : undefined,
}]
: []
// --- Notas ---
const [notas, setNotas] = useState<Recordatorio[]>([])
const [cargandoNotas, setCargandoNotas] = useState(true)
const [notaError, setNotaError] = useState('')
const [nuevaNota, setNuevaNota] = useState('')
const [enviandoNota, setEnviandoNota] = useState(false)
const cargarNotas = useCallback(async () => {
setCargandoNotas(true)
setNotaError('')
try {
const todas = await listarRecordatorios()
setNotas(todas.filter((r) => r.leadId === item.id))
} catch {
setNotaError('No se pudieron cargar las notas.')
} finally {
setCargandoNotas(false)
}
}, [item.id])
useEffect(() => {
void cargarNotas()
}, [cargarNotas])
async function agregarNota(e: FormEvent) {
e.preventDefault()
const texto = nuevaNota.trim()
if (texto === '') return
setEnviandoNota(true)
setNotaError('')
try {
await crearRecordatorio({
titulo: texto,
leadId: item.id,
fechaRecordar: new Date().toISOString(),
})
setNuevaNota('')
await cargarNotas()
} catch {
setNotaError('No se pudo guardar la nota.')
} finally {
setEnviandoNota(false)
}
}
// --- Cerrar con Escape ---
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onCerrar()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCerrar])
return (
<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 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] px-6 py-4">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Detalle del lead</p>
<button
type="button"
onClick={onCerrar}
className="rounded-lg px-2 py-1 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
>
Cerrar ✕
</button>
</div>
{/* Body scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Sección Información */}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Información</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<InfoCampo etiqueta="Nombre" valor={item.nombre} />
{esLd && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
{esOp && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
{esLd && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
{esOp && item.email && <InfoCampo etiqueta="Email" valor={item.email} />}
{esLd && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
{esOp && item.desarrollo && <InfoCampo etiqueta="Desarrollo" valor={item.desarrollo} />}
{esOp && item.modeloInteres && <InfoCampo etiqueta="Modelo" valor={item.modeloInteres} />}
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
<InfoCampo etiqueta="Estado" valor={esOp ? (item.etapa || '—') : item.estado || '—'} />
{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).fechaLimite && (
<InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />
)}
{item.ultimoCambio && (
<div className="col-span-2">
<p className="text-[11px] text-[#94A3B8]">
Último cambio: {item.ultimoCambio.detalle} · {item.ultimoCambio.usuario ?? '—'} ·{' '}
{formatoFechaCorta(item.ultimoCambio.fechaISO.slice(0, 10))}
</p>
</div>
)}
</div>
</section>
{/* Sección Notas */}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Notas</h3>
<form onSubmit={agregarNota} className="flex gap-2 mb-3">
<input
type="text"
value={nuevaNota}
onChange={(e) => setNuevaNota(e.target.value)}
placeholder="Escribe una nota…"
disabled={enviandoNota}
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 disabled:opacity-50"
/>
<button
type="submit"
disabled={enviandoNota || nuevaNota.trim() === ''}
className="shrink-0 rounded-lg bg-accent px-3 py-2 text-xs font-medium text-white shadow-sm transition-all hover:bg-accent-hover disabled:opacity-50"
>
{enviandoNota ? '…' : 'Agregar'}
</button>
</form>
{notaError && (
<p className="text-xs text-red-600 mb-2">{notaError}</p>
)}
{cargandoNotas ? (
<p className="text-xs text-[#94A3B8]">Cargando notas…</p>
) : notas.length === 0 ? (
<p className="text-xs text-[#94A3B8]">Sin notas registradas.</p>
) : (
<ul className="space-y-2">
{[...notas].reverse().map((n) => (
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#0F172A] px-3 py-2">
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
{n.descripcion && (
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>
)}
<p className="mt-0.5 text-[10px] text-[#94A3B8]">
{formatoFechaCorta(n.createdAt.slice(0, 10))}
</p>
</li>
))}
</ul>
)}
</section>
{/* Sección Historial */}
<section>
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8] mb-3">Historial</h3>
<TimelineLead eventos={eventos} />
</section>
</div>
</div>
</div>
)
}
function InfoCampo({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div>
<p className="text-[11px] font-medium text-[#94A3B8] uppercase tracking-wider">{etiqueta}</p>
<p className="text-sm text-[#111827] dark:text-[#E2E8F0]">{valor || '—'}</p>
</div>
)
}

View File

@@ -15,7 +15,7 @@ import {
describe('catálogos', () => {
it('orígenes = vocabulario FRASA sembrado en Monday (Leads y Oportunidades)', () => {
expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'])
expect([...ORIGENES_LEAD]).toEqual(['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'])
})
it('desarrollos = labels reales de la columna Desarrollo del board Leads', () => {

View File

@@ -68,6 +68,8 @@ export interface OportunidadMiDia {
unidadCodigo: string
updatedAt: 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
/** true cuando el item se creó en modo local y aún no se sincroniza a Monday */
pendienteSync?: boolean
}
@@ -112,7 +114,7 @@ export interface ResumenMiDia {
// Labels de la columna 'Origen' (board Leads) / 'Origen del Lead' (Oportunidades),
// vocabulario FRASA verificado y sembrado en Monday el 2026-09-04
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'OTRO'] as const
export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEBOOK FRASA', 'REFERIDO', 'BROCKER', 'OTRO'] as const
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const

View File

@@ -13,6 +13,7 @@ import {
hoyLocal,
DESARROLLOS_LEAD,
ETIQUETA_FASE_APARTADO,
ETIQUETA_FASE_ESCRITURACION,
ETIQUETA_FASE_OPORTUNIDAD,
FASES_APARTADO,
MOTIVOS_NO_COMPRA,
@@ -55,6 +56,8 @@ import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
import KanbanView from '../components/KanbanView'
import TimelineLead, { type EventoLead } from '../components/TimelineLead'
import CalendarioCitas from '../components/CalendarioCitas'
import DetalleLeadModal from '../components/DetalleLeadModal'
function TituloSeccion({ children }: { children: string }) {
return <h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{children}</h2>
@@ -111,6 +114,109 @@ const CLASE_INPUT =
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/** Modal genérico de descarte/cancelación con motivo + subcategoría + comentario */
function ModalDescarteGenerico({
item,
titulo,
onCancelar,
onConfirmar,
enviando,
}: {
item: { id: string; nombre: string }
titulo: string
onCancelar: () => void
onConfirmar: (motivo: MotivoNoCompra, subcategoria: string | undefined, comentario: string) => void
enviando: boolean
}) {
const [motivo, setMotivo] = useState<MotivoNoCompra>(MOTIVOS_NO_COMPRA[0])
const [subcategoria, setSubcategoria] = useState('')
const [comentario, setComentario] = useState('')
const subcategorias = SUBCATEGORIAS_NO_COMPRA[motivo]
return (
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/40 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-2 text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
<form
className="mt-6 space-y-4"
onSubmit={(e) => {
e.preventDefault()
onConfirmar(motivo, subcategoria === '' ? undefined : subcategoria, comentario.trim())
}}
>
<div>
<label htmlFor="motivo-descarte" className={CLASE_LABEL}>
Motivo
</label>
<select
id="motivo-descarte"
value={motivo}
onChange={(e) => {
setMotivo(e.target.value as MotivoNoCompra)
setSubcategoria('')
}}
className={CLASE_INPUT}
>
{MOTIVOS_NO_COMPRA.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
<div>
<label htmlFor="subcategoria-descarte" className={CLASE_LABEL}>
Subcategoría
</label>
<select
id="subcategoria-descarte"
value={subcategoria}
onChange={(e) => setSubcategoria(e.target.value)}
className={CLASE_INPUT}
>
<option value="">— Selecciona —</option>
{subcategorias.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label htmlFor="comentario-descarte" className={CLASE_LABEL}>
Comentario (opcional)
</label>
<textarea
id="comentario-descarte"
value={comentario}
onChange={(e) => setComentario(e.target.value)}
rows={3}
className={CLASE_INPUT}
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancelar}
disabled={enviando}
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] transition-all duration-100 ease-out hover:bg-[#F9FAFB] disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={enviando}
className="rounded-lg bg-red-600 px-5 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-red-700 disabled:opacity-50"
>
{enviando ? 'Procesando…' : 'Confirmar'}
</button>
</div>
</form>
</div>
</div>
)
}
/** Modal de descarte: motivo + subcategoría (catálogo Monday) + comentario opcional */
function ModalDescartar({
lead,
@@ -219,6 +325,7 @@ function FilaOportunidad({
onCotizar,
onAfluencia,
onDescartar,
onVerDetalle,
enviando,
acciones,
}: {
@@ -227,19 +334,25 @@ function FilaOportunidad({
onCotizar: () => void
onAfluencia: () => void
onDescartar: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
}) {
const [conCita, setConCita] = useState(false)
const [fechaCita, setFechaCita] = useState('')
// Sin 'No Asistio' ni 'Abandono': son redundantes con Descartar
const fases = ['Agendo Cita', 'Aparto'] as const
const fases = ['Agendo Cita', 'Afluencia', 'Cotizador'] as const
return (
<li className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
{op.nombre}
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{op.nombre}
</button>
{op.pendienteSync === true && <ChipPendienteSync />}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
@@ -248,6 +361,11 @@ function FilaOportunidad({
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
</p>
{op.fechaCita && (
<p className="mt-0.5 text-[11px] text-accent font-medium">
📅 Cita: {op.fechaCita.replace('T', ' ')}
</p>
)}
{op.ultimoCambio && (
<p className="mt-0.5 text-[11px] text-[#9CA3AF] dark:text-[#64748B]">
Último cambio: {op.ultimoCambio.detalle} · {op.ultimoCambio.usuario ?? '—'} ·{' '}
@@ -291,8 +409,8 @@ function FilaOportunidad({
</button>
{conCita && (
<input
type="date"
aria-label="Fecha de la cita"
type="datetime-local"
aria-label="Fecha y hora de la cita"
value={fechaCita}
onChange={(e) => setFechaCita(e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
@@ -305,9 +423,7 @@ function FilaOportunidad({
type="button"
disabled={enviando}
onClick={() => onEtapa(f)}
className={f === 'Aparto'
? 'rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-green-700 disabled:opacity-50'
: CLASE_BOTON_ACCION}
className={CLASE_BOTON_ACCION}
>
{ETIQUETA_FASE_OPORTUNIDAD[f]}
</button>
@@ -330,10 +446,14 @@ function FilaOportunidad({
function FilaApartado({
apartado,
onFase,
onCancelacion,
onVerDetalle,
enviando,
}: {
apartado: ApartadoMiDia
onFase: (fase: FaseApartado) => void
onCancelacion: () => void
onVerDetalle?: () => void
enviando: boolean
}) {
const [docsAbiertos, setDocsAbiertos] = useState(false)
@@ -342,7 +462,13 @@ function FilaApartado({
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
{apartado.nombre}
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{apartado.nombre}
</button>
{apartado.pendienteSync === true && <ChipPendienteSync />}
{apartado.unidadCodigo ? (
<span className="text-xs font-normal text-[#94A3B8]">{apartado.unidadCodigo}</span>
@@ -375,6 +501,10 @@ function FilaApartado({
window.location.href = `/client/?apartado=${apartado.id}`
return
}
if (fase === 'Cancelacion Apartado') {
onCancelacion()
return
}
onFase(fase)
}}
className={
@@ -505,8 +635,8 @@ function PanelDocumentosApartado({ itemId }: { itemId: string }) {
)
}
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero'
/** Tabs del CRM personal: Mis leads / Mis oportunidades / Mis apartados / Mi tablero / Calendario */
type TabCrm = 'leads' | 'oportunidades' | 'apartados' | 'escrituracion' | 'tablero' | 'calendario'
function TabsCrm({
activa,
@@ -527,6 +657,7 @@ function TabsCrm({
{ id: 'apartados', titulo: 'Apartados' },
{ id: 'escrituracion', titulo: 'Escrituración' },
{ id: 'tablero', titulo: 'Tablero' },
{ id: 'calendario', titulo: 'Calendario' },
]
return (
<div className="mb-4 flex items-center gap-1 overflow-x-auto rounded-xl border border-[#E5E7EB] bg-white p-1 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" role="tablist">
@@ -575,6 +706,7 @@ function FilaLead({
onConvertir,
onDescartar,
onTimeline,
onVerDetalle,
enviando,
acciones,
}: {
@@ -583,6 +715,7 @@ function FilaLead({
onConvertir: () => void
onDescartar: () => void
onTimeline?: () => void
onVerDetalle?: () => void
enviando: boolean
acciones?: string[]
}) {
@@ -591,7 +724,13 @@ function FilaLead({
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium">
{lead.nombre}
<button
type="button"
onClick={onVerDetalle}
className="text-left text-accent hover:underline cursor-pointer"
>
{lead.nombre}
</button>
<ScoreBadge score={calcularScore(lead)} />
{lead.pendienteSync === true && <ChipPendienteSync />}
{/* Insignia de procedencia: gris = tablero Leads, azul = Oportunidades */}
@@ -679,6 +818,11 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
const [nota, setNota] = useState<string | null>(null)
const [apartadoSeleccionado, setApartadoSeleccionado] = useState<ApartadoMiDia | null>(null)
const [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(null)
const [apartadoCancelando, setApartadoCancelando] = useState<{
id: string
nombre: string
} | null>(null)
const [filtros, setFiltros] = useState<FiltrosCRM>({ texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' })
const [offline, setOffline] = useState(!navigator.onLine)
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
@@ -1038,7 +1182,7 @@ 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: resumen.misApartados.length, tablero: 0,
apartados: resumen.misApartados.length, escrituracion: resumen.misApartados.length, tablero: 0, calendario: 0,
}}
onCambiar={setTabCrm}
vistaKanban={vistaKanban}
@@ -1097,6 +1241,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
onAccion={(estado, fecha) => void ejecutar(() => cambiarEstadoLead(lead.id, estado, fecha, lead.fuente), 'No se pudo actualizar el lead')}
onConvertir={() => void convertir(lead)}
onDescartar={() => setDescartando(lead)}
onVerDetalle={() => setLeadSeleccionado(lead)}
onTimeline={() => setLeadTimeline({
nombre: lead.nombre,
eventos: lead.ultimoCambio ? [{
@@ -1152,6 +1297,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
window.location.href = urlAfluenciaParaOportunidad(op)
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
onVerDetalle={() => setLeadSeleccionado(op)}
acciones={acciones}
/>
))}
@@ -1196,6 +1342,8 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<FilaApartado
apartado={a}
enviando={enviando}
onCancelacion={() => setApartadoCancelando({ id: a.id, nombre: a.nombre })}
onVerDetalle={() => setLeadSeleccionado(a)}
onFase={(fase) => {
if (fase === 'Firma Cliente') {
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
@@ -1246,17 +1394,25 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</div>
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{['Seguimiento', 'Llamada Escrituras', 'X Escriturar', 'Banco/Infonavit', 'X Autorizar', 'Escriturado', 'Cancelo'].map((fase) => (
{(['Seguimiento', 'Llamada Escrituras', 'X Escriturar', 'Banco/Infonavit', 'X Autorizar', 'Escriturado', 'Cancelo'] as const).map((fase) => (
<button
key={fase}
type="button"
disabled={enviando}
onClick={() => void ejecutar(() => cambiarEstadoApartado(apartado.id, fase), 'No se pudo actualizar')}
onClick={() => {
if (fase === 'Cancelo') {
setApartadoCancelando({ id: apartado.id, nombre: apartado.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(apartado.id, fase), 'No se pudo actualizar')
}}
className={fase === apartado.estado
? 'rounded-lg border border-accent bg-accent/5 px-3 py-1.5 text-xs font-medium text-accent shadow-sm disabled:opacity-50'
: CLASE_BOTON_ACCION}
: fase === 'Cancelo'
? 'rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:bg-[#0F172A] dark:border-red-800 dark:text-red-400'
: CLASE_BOTON_ACCION}
>
{fase}
{ETIQUETA_FASE_ESCRITURACION[fase]}
</button>
))}
</div>
@@ -1270,6 +1426,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{tabCrm === 'tablero' && (
<DashboardVendedor />
)}
{tabCrm === 'calendario' && (
<section aria-label="Calendario">
<CalendarioCitas
actividades={resumen.misActividadesHoy}
oportunidades={resumen.misOportunidades.items}
/>
</section>
)}
</section>
<section aria-label="Mis capturas de afluencia">
@@ -1582,6 +1747,23 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
/>
)}
{apartadoCancelando && (
<ModalDescarteGenerico
item={apartadoCancelando}
titulo="Cancelar apartado"
enviando={enviando}
onCancelar={() => setApartadoCancelando(null)}
onConfirmar={(motivo, subcategoria, comentario) => {
const item = apartadoCancelando
setApartadoCancelando(null)
void ejecutar(
() => cambiarEstadoApartado(item.id, 'Cancelacion Apartado', motivo, subcategoria, comentario),
'No se pudo cancelar el apartado',
)
}}
/>
)}
{apartadoSeleccionado && (
<NotasApartado
apartado={apartadoSeleccionado}
@@ -1610,6 +1792,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</div>
</div>
)}
{leadSeleccionado && (
<DetalleLeadModal
item={leadSeleccionado}
onCerrar={() => setLeadSeleccionado(null)}
/>
)}
</>
)
}

View File

@@ -88,11 +88,22 @@ export function convertirLead(itemId: string): Promise<{ oportunidadId: string }
}
/** POST /apartados/:itemId/estado — botones de fase de "Mis apartados" */
export function cambiarEstadoApartado(itemId: string, estado: string): Promise<unknown> {
export function cambiarEstadoApartado(
itemId: string,
estado: string,
motivo?: string,
subcategoria?: string,
comentario?: string,
): Promise<unknown> {
return fetchJson(`/apartados/${itemId}/estado`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ estado }),
body: JSON.stringify({
estado,
...(motivo !== undefined && motivo !== '' ? { motivo } : {}),
...(subcategoria !== undefined && subcategoria !== '' ? { subcategoria } : {}),
...(comentario !== undefined && comentario !== '' ? { comentario } : {}),
}),
})
}

View File

@@ -0,0 +1,44 @@
import { conAuth, manejar401 } from './auth'
const API_URL = import.meta.env.VITE_API_URL ?? '/api'
export interface Recordatorio {
id: string
titulo: string
descripcion: string | null
leadId: string | null
fechaRecordar: string // ISO
completado: boolean
createdAt: string
}
/** GET /api/recordatorios — listar recordatorios del usuario actual */
export async function listarRecordatorios(): Promise<Recordatorio[]> {
const res = await fetch(`${API_URL}/recordatorios`, conAuth())
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
return (await res.json()) as Recordatorio[]
}
/** POST /api/recordatorios — crear un recordatorio para un lead */
export async function crearRecordatorio(data: {
titulo: string
descripcion?: string
leadId: string
fechaRecordar: string
}): Promise<Recordatorio> {
const res = await fetch(`${API_URL}/recordatorios`, conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}))
manejar401(res)
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(body?.error ?? `Error ${res.status}`)
}
return (await res.json()) as Recordatorio
}