fix(mi-dia): 5 bugs críticos + 8 mejoras UX

Bugs críticos: descartar, kanban drag&drop, escrituración, fases API, export filtros
UX: tel clicable, WhatsApp, filtros por tab, tareas, calendario, estado, notas, navegación
This commit is contained in:
2026-09-11 02:05:25 +00:00
parent fabf507aa0
commit 075d47c304
9 changed files with 463 additions and 210 deletions

View File

@@ -28,12 +28,16 @@ export const FASES_APARTADO_PERMITIDAS = [
'Contrato Enkontrol',
'Firma Cliente',
'Cancelacion Apartado',
'Venta',
'Seguimiento',
'Integracion',
'Llamada Escrituras',
'X Escriturar',
'Banco/Infonavit',
'X Autorizar',
'Autorizado',
'Escriturado',
'Titulado',
'Cancelo',
] as const

View File

@@ -334,10 +334,10 @@ export async function resumenMiDia(
const miosOportunidadesIds = new Set(
capturas.filter((c) => c.oportunidadId).map((c) => c.oportunidadId),
)
const miosApartados = apartados.filter(
(a) =>
(esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id)) && !esEstadoFinalApartado(a.estado),
const miosApartadosTodos = apartados.filter((a) =>
esMio(a.vendedorIds, a.id) || apartadosDeMisClientes.has(a.id) || miosOportunidadesIds.has(a.id),
)
const miosApartados = miosApartadosTodos.filter((a) => !esEstadoFinalApartado(a.estado))
const [cambiosLeads, cambiosOportunidades, cambiosApartados] = await Promise.all([
monday.getUltimosCambios(
@@ -377,8 +377,9 @@ export async function resumenMiDia(
// "Mis apartados": Vendedor = yo, estado no final del pipeline Enkontrol,
// por fecha límite ascendente (sin fecha al final)
// Todos los apartados incluyendo terminales (para pestaña Escrituración)
const todosApartados: ApartadoMiDia[] = miosApartados
// Todos los apartados míos incluyendo terminales (para pestaña Escrituración);
// se calcula desde la lista ANTES de excluir estados finales
const todosApartados: ApartadoMiDia[] = miosApartadosTodos
.map((a) => ({
id: a.id,
nombre: a.nombre,

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'
import type { ActividadMiDia, OportunidadMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia'
import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia'
import { getSesion } from '../services/auth'
/** Evento unificado para el calendario */
interface EventoCalendario {
@@ -12,16 +13,56 @@ interface EventoCalendario {
detalle?: string
estado?: string
idOriginal: string
/** Oportunidad fuente (solo tipo 'oportunidad'): habilita llamar/WhatsApp/ver detalle */
item?: OportunidadMiDia
}
interface Props {
actividades: ActividadMiDia[]
oportunidades: OportunidadMiDia[]
/** Abre el DetalleLeadModal de la oportunidad del evento (desde MiDiaPage) */
onVerDetalle?: (item: OportunidadMiDia) => void
}
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']
const CLASE_BOTON_EVENTO =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
/** Botones de contacto del evento: llamar, WhatsApp y ver detalle de la oportunidad */
function AccionesEvento({ item, onVerDetalle }: { item: OportunidadMiDia; onVerDetalle?: (item: OportunidadMiDia) => void }) {
const vendedor = getSesion()?.usuario.nombre ?? ''
const tieneTelefono = item.telefono.trim() !== ''
return (
<div className="flex flex-wrap gap-2 pt-2">
{tieneTelefono && (
<a
href={`tel:${item.telefono.replace(/\s+/g, '')}`}
className={CLASE_BOTON_EVENTO}
>
📞 Llamar
</a>
)}
{tieneTelefono && (
<a
href={urlWhatsApp(item.nombre, item.telefono, vendedor)}
target="_blank"
rel="noopener noreferrer"
className={CLASE_BOTON_EVENTO}
>
💬 WhatsApp
</a>
)}
{onVerDetalle && (
<button type="button" onClick={() => onVerDetalle(item)} className={CLASE_BOTON_EVENTO}>
Ver detalle
</button>
)}
</div>
)
}
function obtenerSemanaBase(fecha: Date): Date {
const d = new Date(fecha)
const diaSemana = d.getDay()
@@ -48,7 +89,7 @@ function parsearFechaCita(fechaCita: string): { fecha: string; hora?: string } {
return { fecha: fechaCita }
}
export default function CalendarioCitas({ actividades, oportunidades }: Props) {
export default function CalendarioCitas({ actividades, oportunidades, onVerDetalle }: Props) {
const hoy = useMemo(() => {
const d = new Date()
d.setHours(0, 0, 0, 0)
@@ -87,6 +128,7 @@ export default function CalendarioCitas({ actividades, oportunidades }: Props) {
detalle: op.etapa,
estado: op.etapa,
idOriginal: op.id,
item: op,
})
}
@@ -275,6 +317,9 @@ export default function CalendarioCitas({ actividades, oportunidades }: Props) {
</span>
</p>
)}
{eventoSeleccionado.item && (
<AccionesEvento item={eventoSeleccionado.item} onVerDetalle={onVerDetalle} />
)}
</div>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia'
import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios'
import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia'
import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
import { getSesion } from '../services/auth'
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
@@ -22,6 +23,9 @@ function esOportunidad(item: LeadItem): item is OportunidadMiDia {
type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros'
const CLASE_ACCION_RAPIDA =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
{ id: 'info', titulo: 'Información del cliente potencial', icono: User },
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar },
@@ -33,6 +37,7 @@ const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) {
const esOp = esOportunidad(item)
const esLd = esLead(item)
const vendedor = getSesion()?.usuario.nombre ?? ''
const [tab, setTab] = useState<TabId>('info')
// --- Notas ---
@@ -42,6 +47,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
const [nuevaNota, setNuevaNota] = useState('')
const [enviandoNota, setEnviandoNota] = useState(false)
const [busquedaNota, setBusquedaNota] = useState('')
// --- Tareas ---
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
const cargarNotas = useCallback(async () => {
setCargandoNotas(true)
@@ -60,13 +67,25 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
if (!nuevaNota.trim()) return
setEnviandoNota(true)
try {
await crearRecordatorio({ titulo: nuevaNota.trim(), leadId: item.id, clienteClave: calcularClienteClave(item.nombre, (item as any).telefono), fechaRecordar: new Date().toISOString() })
// fechaRecordar a 7 días en el futuro: la nota nace como tarea pendiente de la semana
await crearRecordatorio({ titulo: nuevaNota.trim(), leadId: item.id, clienteClave: calcularClienteClave(item.nombre, (item as any).telefono), fechaRecordar: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() })
setNuevaNota('')
await cargarNotas()
} catch { setNotaError('No se pudo guardar.') }
finally { setEnviandoNota(false) }
}
/** Marca una tarea (recordatorio) como completada y refresca la lista */
async function completarTarea(id: string) {
setCompletandoTarea(id)
setNotaError('')
try {
await completarRecordatorio(id)
await cargarNotas()
} catch { setNotaError('No se pudo completar la tarea.') }
finally { setCompletandoTarea(null) }
}
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onCerrar() }
window.addEventListener('keydown', h)
@@ -107,6 +126,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{/* Contenido */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{tab === 'info' && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoCampo etiqueta="Nombre" valor={item.nombre} />
{(esLd || esOp) && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />}
@@ -125,6 +145,25 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
</div>
)}
</div>
{/* Acciones rápidas de contacto (bajo el teléfono) */}
{(esLd || esOp) && item.telefono && (
<div className="flex flex-wrap items-center gap-2">
<a href={`tel:${item.telefono.replace(/\s+/g, '')}`} className={CLASE_ACCION_RAPIDA}>📞 Llamar</a>
<a
href={urlWhatsApp(item.nombre, item.telefono, vendedor)}
target="_blank"
rel="noopener noreferrer"
className={CLASE_ACCION_RAPIDA}
>
💬 WhatsApp
</a>
{item.email && (
<a href={`mailto:${item.email}`} className={CLASE_ACCION_RAPIDA}>📧 Email</a>
)}
</div>
)}
</>
)}
{tab === 'cita' && (
@@ -144,13 +183,22 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{tab === 'tareas' && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>
{notaError && <p className="text-xs text-red-600" role="alert">{notaError}</p>}
{notasFiltradas.filter((n) => !n.completado).length === 0 ? (
<p className="text-sm text-[#94A3B8]">No hay tareas pendientes.</p>
) : (
<ul className="space-y-2">
{notasFiltradas.filter((n) => !n.completado).map((n) => (
<li key={n.id} className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-3 py-2">
<input type="checkbox" className="h-4 w-4 rounded" readOnly />
<input
type="checkbox"
className="h-4 w-4 shrink-0 cursor-pointer rounded accent-accent"
aria-label={`Completar tarea: ${n.titulo}`}
title="Marcar como completada"
checked={false}
disabled={completandoTarea === n.id}
onChange={() => void completarTarea(n.id)}
/>
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span>
<span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</span>
</li>

View File

@@ -5,6 +5,7 @@ export interface FiltrosCRM {
texto: string
desarrollo: string
origen: string
estado: string
fechaDesde: string
fechaHasta: string
}
@@ -16,14 +17,16 @@ const CLASE_SELECT =
interface Props {
valor: FiltrosCRM
onChange: (f: FiltrosCRM) => void
/** Estados posibles del tab activo; el select de Estado se muestra solo si se pasan */
estados?: readonly string[]
}
export function FiltrosAvanzados({ valor, onChange }: Props) {
export function FiltrosAvanzados({ valor, onChange, estados }: Props) {
const actualizar = (campo: keyof FiltrosCRM, v: string) =>
onChange({ ...valor, [campo]: v })
const hayActivos =
valor.texto !== '' || valor.desarrollo !== '' || valor.origen !== '' || valor.fechaDesde !== '' || valor.fechaHasta !== ''
valor.texto !== '' || valor.desarrollo !== '' || valor.origen !== '' || valor.estado !== '' || valor.fechaDesde !== '' || valor.fechaHasta !== ''
return (
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
@@ -61,6 +64,19 @@ export function FiltrosAvanzados({ valor, onChange }: Props) {
</select>
</label>
{/* Estado: opciones según el tab activo (leads / oportunidades / apartados / escrituración) */}
{estados !== undefined && estados.length > 0 && (
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Estado</span>
<select value={valor.estado} onChange={(e) => actualizar('estado', e.target.value)} className={CLASE_SELECT}>
<option value="">Todos</option>
{estados.map((e) => (
<option key={e} value={e}>{e}</option>
))}
</select>
</label>
)}
{/* Fecha desde */}
<label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Desde</span>
@@ -87,7 +103,7 @@ export function FiltrosAvanzados({ valor, onChange }: Props) {
{hayActivos && (
<button
type="button"
onClick={() => onChange(EMPTY)}
onClick={() => onChange(FILTROS_VACIOS)}
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] text-xs font-medium text-[#64748B] dark:text-[#94A3B8] shadow-sm transition-colors hover:bg-[#F9FAFB]"
>
Limpiar filtros
@@ -97,7 +113,7 @@ export function FiltrosAvanzados({ valor, onChange }: Props) {
)
}
const EMPTY: FiltrosCRM = { texto: '', desarrollo: '', origen: '', fechaDesde: '', fechaHasta: '' }
export const FILTROS_VACIOS: FiltrosCRM = { texto: '', desarrollo: '', origen: '', estado: '', fechaDesde: '', fechaHasta: '' }
/**
* Filtra un array de leads/opportunidades/apartados según los filtros activos.
@@ -113,6 +129,7 @@ export function aplicarFiltros(
texto?: (item: any) => string
desarrollo?: (item: any) => string
origen?: (item: any) => string
estado?: (item: any) => string
fecha?: (item: any) => string
},
): typeof items {
@@ -134,6 +151,11 @@ export function aplicarFiltros(
if (accessor.origen(item) !== filtros.origen) return false
}
// Estado (comparación case-insensitive: Monday devuelve labels con mayúsculas variables)
if (filtros.estado !== '' && accessor.estado) {
if (accessor.estado(item).trim().toLowerCase() !== filtros.estado.trim().toLowerCase()) return false
}
// Fecha desde
if (filtros.fechaDesde !== '' && accessor.fecha) {
const fecha = accessor.fecha(item)

View File

@@ -26,8 +26,9 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
const [arrastrando, setArrastrando] = useState<string | null>(null)
const onDragStart = useCallback((e: React.DragEvent, itemId: string) => {
setArrastrando(itemId)
e.dataTransfer.setData('text/plain', itemId)
e.dataTransfer.effectAllowed = 'move'
setArrastrando(itemId)
}, [])
const onDragOver = useCallback((e: React.DragEvent) => {
@@ -38,11 +39,12 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
const onDrop = useCallback((e: React.DragEvent, columnaId: string) => {
e.preventDefault()
const itemId = e.dataTransfer.getData('text/plain')
if (itemId && itemId !== arrastrando) {
onCambiarEstado(itemId, columnaId)
}
setArrastrando(null)
}, [arrastrando, onCambiarEstado])
if (itemId === '') return
const item = items.find((i) => i.id === itemId)
if (!item || obtenerColumna(item) === columnaId) return
onCambiarEstado(itemId, columnaId)
}, [items, obtenerColumna, onCambiarEstado])
return (
<div className="flex gap-3 overflow-x-auto pb-4 scroll-smooth">

View File

@@ -119,6 +119,18 @@ export const ORIGENES_LEAD = ['PASE', 'PROSPECCION ASESOR', 'ACTIVACION', 'FACEB
// Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const
// Estados de la columna status del board Leads (vocabulario real del API:
// ESTADOS_LEAD_PERMITIDOS + NUEVO/DESCARTADO); alimenta el filtro por estado
export const ESTADOS_LEAD = [
'NUEVO',
'CONTACTADO',
'Intento de contacto',
'INTERESADO',
'AGENDA CITA',
'CALIFICADO',
'DESCARTADO',
] as const
// Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
export const MOTIVOS_NO_COMPRA = [
'Precio',
@@ -165,6 +177,22 @@ export function componerTelefono(lada: ClaveLada, numero: string): string {
return `${prefijo} ${limpio}`
}
/**
* Normaliza un teléfono a dígitos para enlaces tel:/wa.me. Si el número no trae
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
*/
export function telefonoParaEnlace(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 10) return `52${digitos}`
return digitos
}
/** URL de WhatsApp con saludo FRASA prellenado: "Hola {nombre}, soy {vendedor} de FRASA" */
export function urlWhatsApp(nombre: string, telefono: string, vendedor: string): string {
const texto = encodeURIComponent(`Hola ${nombre}, soy ${vendedor} de FRASA`)
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
}
// Fases del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
export const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]

View File

@@ -10,10 +10,13 @@ import {
formatoFechaCorta,
hoyLocal,
DESARROLLOS_LEAD,
ESTADOS_LEAD,
ETIQUETA_FASE_APARTADO,
ETIQUETA_FASE_ESCRITURACION,
ETIQUETA_FASE_OPORTUNIDAD,
FASES_APARTADO,
FASES_ESCRITURACION,
FASES_OPORTUNIDAD,
ORIGENES_LEAD,
SLOTS_DOCS_APARTADO,
componerTelefono,
@@ -21,6 +24,7 @@ import {
textoDiasRestantes,
urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad,
urlWhatsApp,
type ApartadoMiDia,
type ClaveLada,
type FaseApartado,
@@ -44,7 +48,7 @@ import {
import { guardarResumen, cargarResumenLocal } from '../services/offline-db'
import { suscribirPush } from '../services/push'
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar'
import { FiltrosAvanzados, aplicarFiltros, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { FiltrosAvanzados, aplicarFiltros, FILTROS_VACIOS, type FiltrosCRM } from '../components/FiltrosAvanzados'
import { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge'
@@ -107,6 +111,35 @@ const CLASE_INPUT =
const CLASE_LABEL = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
/** Motivos de no compra: valores EXACTOS que valida el API (z.enum MOTIVOS_NO_COMPRA) */
const MOTIVOS_NO_COMPRA = ['Precio', 'Producto', 'Ubicacion', 'Financiamiento', 'Competencia', 'Cliente', 'Timing'] as const
/** Teléfono clicable (tel:) + botón de WhatsApp con saludo FRASA prellenado */
function EnlacesContacto({ nombre, telefono }: { nombre: string; telefono: string }) {
const vendedor = getSesion()?.usuario.nombre ?? ''
if (telefono.trim() === '') return <span>Sin teléfono</span>
return (
<>
<a
href={`tel:${telefono.replace(/\s+/g, '')}`}
className="text-accent hover:underline"
>
{telefono}
</a>
<a
href={urlWhatsApp(nombre, telefono, vendedor)}
target="_blank"
rel="noopener noreferrer"
title={`Enviar WhatsApp a ${nombre}`}
aria-label={`Enviar WhatsApp a ${nombre}`}
className="ml-1.5 hover:underline"
>
💬
</a>
</>
)
}
/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
function FilaOportunidad({
@@ -146,7 +179,7 @@ function FilaOportunidad({
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{op.telefono || 'Sin teléfono'}
<EnlacesContacto nombre={op.nombre} telefono={op.telefono} />
{op.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
@@ -269,8 +302,8 @@ function ModalDescarteGenerico({
onChange={(e) => setMotivo(e.target.value)}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm"
>
{['Timing', 'Cliente', 'Competencia', 'Financiamiento', 'Ubicación', 'Producto', 'Precio'].map((m) => (
<option key={m} value={m}>{m}</option>
{MOTIVOS_NO_COMPRA.map((m) => (
<option key={m} value={m}>{m === 'Ubicacion' ? 'Ubicación' : m}</option>
))}
</select>
</div>
@@ -351,9 +384,10 @@ function FilaApartado({
disabled={enviando}
onClick={() => {
// Alta Enkontrol: abre el alta de cliente prellenada con
// todo lo recolectado del apartado (ligada, sin duplicar)
// todo lo recolectado del apartado (ligada, sin duplicar).
// En pestaña nueva para no perder el estado de Mi día.
if (fase === 'Alta Enkontrol') {
window.location.href = `/client/?apartado=${apartado.id}`
window.open(`/client/?apartado=${apartado.id}`, '_blank')
return
}
if (fase === 'Cancelacion Apartado') {
@@ -598,7 +632,7 @@ function FilaLead({
</span>
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{lead.telefono || 'Sin teléfono'}
<EnlacesContacto nombre={lead.nombre} telefono={lead.telefono} />
{lead.desarrollo ? ` · ${lead.desarrollo}` : ''}
{lead.origen ? ` · ${lead.origen}` : ''}
</p>
@@ -662,87 +696,113 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
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: '' })
// Filtros independientes por tab: cada pestaña conserva su propia búsqueda
const [filtrosPorTab, setFiltrosPorTab] = useState<Record<TabCrm, FiltrosCRM>>({
leads: FILTROS_VACIOS,
oportunidades: FILTROS_VACIOS,
apartados: FILTROS_VACIOS,
escrituracion: FILTROS_VACIOS,
tablero: FILTROS_VACIOS,
calendario: FILTROS_VACIOS,
})
const filtros = filtrosPorTab[tabCrm]
const actualizarFiltros = (f: FiltrosCRM) => setFiltrosPorTab((prev) => ({ ...prev, [tabCrm]: f }))
// Estados posibles por tab: alimenta el select "Estado" de FiltrosAvanzados
const estadosPorTab: Partial<Record<TabCrm, readonly string[]>> = {
leads: ESTADOS_LEAD,
oportunidades: FASES_OPORTUNIDAD,
apartados: FASES_APARTADO,
escrituracion: FASES_ESCRITURACION,
}
const [offline, setOffline] = useState(!navigator.onLine)
const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
// Kanban toggle
const [vistaKanban, setVistaKanban] = useState(false)
// Configuración de columnas por pestaña
// Configuración de columnas por pestaña. Los IDs son los valores EXACTOS que
// valida el API (z.enum en los controllers). NUEVO/DESCARTADO/PERDIDO son
// columnas de solo-display: su drop abre el modal de descarte o no hace nada.
const columnasLeads = [
{ id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' },
{ id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' },
{ id: 'Intento de contacto', titulo: 'Intento de contacto', color: 'border-t-sky-500' },
{ id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' },
{ id: 'AGENDO', titulo: 'Agendo', color: 'border-t-green-500' },
{ id: 'AGENDA CITA', titulo: 'Agenda Cita', color: 'border-t-green-500' },
{ id: 'CALIFICADO', titulo: 'Calificado', color: 'border-t-teal-500' },
{ id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' },
]
const columnasOportunidades = [
{ id: 'AFLUENCIA', titulo: 'Afluencia', color: 'border-t-purple-500' },
{ id: 'COTIZADOR', titulo: 'Cotizador', color: 'border-t-blue-500' },
{ id: 'AGENDO CITA', titulo: 'Agendó Cita', color: 'border-t-green-500' },
{ id: 'APARTO', titulo: 'Apartó', color: 'border-t-emerald-600' },
{ id: 'Afluencia', titulo: 'Afluencia', color: 'border-t-purple-500' },
{ id: 'Cotizador', titulo: 'Cotizador', color: 'border-t-blue-500' },
{ id: 'Agendo Cita', titulo: 'Agendó Cita', color: 'border-t-green-500' },
{ id: 'Aparto', titulo: 'Apartó', color: 'border-t-emerald-600' },
{ id: 'No Asistio', titulo: 'No Asistió', color: 'border-t-orange-500' },
{ id: 'Abandono', titulo: 'Abandono', color: 'border-t-yellow-600' },
{ id: 'PERDIDO', titulo: 'Perdido', color: 'border-t-red-500' },
]
const columnasApartados = [
{ id: 'PAGO APARTADO', titulo: 'Pago Apartado', color: 'border-t-blue-500' },
{ id: 'ALTA ENKONTROL', titulo: 'Alta Enkontrol', color: 'border-t-yellow-500' },
{ id: 'ENTREGO DOCUMENTACION', titulo: 'Entregó Docs', color: 'border-t-orange-500' },
{ id: 'CITA CLIENTES', titulo: 'Cita Clientes', color: 'border-t-green-500' },
{ id: 'CONTRATO ENKONTROL', titulo: 'Contrato Enkontrol', color: 'border-t-emerald-600' },
{ id: 'FIRMA CLIENTE', titulo: 'Firma Cliente', color: 'border-t-accent' },
{ id: 'CANCELACION APARTADO', titulo: 'Cancelación', color: 'border-t-red-500' },
{ id: 'Pago Apartado', titulo: 'Pago Apartado', color: 'border-t-blue-500' },
{ id: 'Alta Enkontrol', titulo: 'Alta Enkontrol', color: 'border-t-yellow-500' },
{ id: 'Entrego Documentacion', titulo: 'Entregó Docs', color: 'border-t-orange-500' },
{ id: 'Cita Clientes', titulo: 'Cita Clientes', color: 'border-t-green-500' },
{ id: 'Contrato Enkontrol', titulo: 'Contrato Enkontrol', color: 'border-t-emerald-600' },
{ id: 'Firma Cliente', titulo: 'Firma Cliente', color: 'border-t-accent' },
{ id: 'Cancelacion Apartado', titulo: 'Cancelación', color: 'border-t-red-500' },
]
const columnasEscrituracion = [
{ id: 'SEGUIMIENTO', titulo: 'Seguimiento', color: 'border-t-gray-400' },
{ id: 'VENTA', titulo: 'Venta', color: 'border-t-gray-400' },
{ id: 'SEGUIMIENTO', titulo: 'Seguimiento', color: 'border-t-blue-500' },
{ id: 'INTEGRACION', titulo: 'Integración', color: 'border-t-yellow-500' },
{ id: 'X AUTORIZAR', titulo: 'X Autorizar', color: 'border-t-orange-500' },
{ id: 'AUTORIZADO', titulo: 'Autorizado', color: 'border-t-green-500' },
{ id: 'X ESCRITURAR', titulo: 'X Escriturar', color: 'border-t-emerald-500' },
{ id: 'ESCRITURADO', titulo: 'Escriturado', color: 'border-t-emerald-600' },
{ id: 'TITULADO', titulo: 'Titulado', color: 'border-t-purple-600' },
{ id: 'CANCELO', titulo: 'Canceló', color: 'border-t-red-500' },
{ id: 'Venta', titulo: 'Venta', color: 'border-t-gray-400' },
{ id: 'Seguimiento', titulo: 'Seguimiento', color: 'border-t-blue-500' },
{ id: 'Integracion', titulo: 'Integración', color: 'border-t-yellow-500' },
{ id: 'X Autorizar', titulo: 'X Autorizar', color: 'border-t-orange-500' },
{ id: 'Autorizado', titulo: 'Autorizado', color: 'border-t-green-500' },
{ id: 'X Escriturar', titulo: 'X Escriturar', color: 'border-t-emerald-500' },
{ id: 'Escriturado', titulo: 'Escriturado', color: 'border-t-emerald-600' },
{ id: 'Titulado', titulo: 'Titulado', color: 'border-t-purple-600' },
{ id: 'Cancelo', titulo: 'Canceló', color: 'border-t-red-500' },
]
const obtenerColumnaLead = (lead: LeadMiDia) => {
const e = (lead.estado ?? '').toUpperCase()
if (e.includes('DESCART')) return 'DESCARTADO'
if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDO'
if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDA CITA'
if (e.includes('CALIFIC')) return 'CALIFICADO'
if (e.includes('INTENTO')) return 'Intento de contacto'
if (e.includes('INTERES')) return 'INTERESADO'
if (e.includes('CONTACT')) return 'CONTACTADO'
return 'NUEVO'
}
const obtenerColumnaOp = (op: OportunidadMiDia) => {
const e = (op.etapa ?? '').toUpperCase()
if (e.includes('APARTO')) return 'APARTO'
if (e.includes('PERDIDO') || e.includes('PERDI')) return 'PERDIDO'
if (e.includes('COTIZ')) return 'COTIZADOR'
if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDO CITA'
return 'AFLUENCIA'
if (e.includes('ASISTIO')) return 'No Asistio'
if (e.includes('ABANDONO')) return 'Abandono'
if (e.includes('APARTO')) return 'Aparto'
if (e.includes('COTIZ')) return 'Cotizador'
if (e.includes('AGENDO') || e.includes('CITA')) return 'Agendo Cita'
return 'Afluencia'
}
const obtenerColumnaApartado = (a: ApartadoMiDia) => {
const e = (a.estado ?? '').toUpperCase()
if (e.includes('PAGO')) return 'PAGO APARTADO'
if (e.includes('ALTA') && e.includes('ENKONTROL')) return 'ALTA ENKONTROL'
if (e.includes('ENTREG') || e.includes('DOC')) return 'ENTREGO DOCUMENTACION'
if (e.includes('CITA')) return 'CITA CLIENTES'
if (e.includes('CONTRATO')) return 'CONTRATO ENKONTROL'
if (e.includes('FIRMA')) return 'FIRMA CLIENTE'
if (e.includes('CANCEL')) return 'CANCELACION APARTADO'
return 'PAGO APARTADO'
if (e.includes('PAGO')) return 'Pago Apartado'
if (e.includes('ALTA') && e.includes('ENKONTROL')) return 'Alta Enkontrol'
if (e.includes('ENTREG') || e.includes('DOC')) return 'Entrego Documentacion'
if (e.includes('CITA')) return 'Cita Clientes'
if (e.includes('CONTRATO')) return 'Contrato Enkontrol'
if (e.includes('FIRMA')) return 'Firma Cliente'
if (e.includes('CANCEL')) return 'Cancelacion Apartado'
return 'Pago Apartado'
}
const obtenerColumnaEscrituracion = (a: ApartadoMiDia) => {
const e = (a.estado ?? '').toUpperCase()
if (e.includes('VENTA')) return 'VENTA'
if (e.includes('SEGUIMIENTO')) return 'SEGUIMIENTO'
if (e.includes('INTEGRACION')) return 'INTEGRACION'
if (e.includes('X AUTORIZAR')) return 'X AUTORIZAR'
if (e.includes('AUTORIZADO')) return 'AUTORIZADO'
if (e.includes('X ESCRITURAR')) return 'X ESCRITURAR'
if (e.includes('ESCRITURADO')) return 'ESCRITURADO'
if (e.includes('TITULADO')) return 'TITULADO'
if (e.includes('CANCEL')) return 'CANCELO'
return 'VENTA'
if (e.includes('VENTA')) return 'Venta'
if (e.includes('SEGUIMIENTO')) return 'Seguimiento'
if (e.includes('INTEGRACION')) return 'Integracion'
if (e.includes('X AUTORIZAR')) return 'X Autorizar'
if (e.includes('AUTORIZADO')) return 'Autorizado'
if (e.includes('X ESCRITURAR')) return 'X Escriturar'
if (e.includes('ESCRITURADO')) return 'Escriturado'
if (e.includes('TITULADO')) return 'Titulado'
if (e.includes('CANCEL')) return 'Cancelo'
return 'Venta'
}
@@ -854,7 +914,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
} catch {
toast.error('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.')
}
window.location.href = urlCotizadorParaOportunidad(op)
window.open(urlCotizadorParaOportunidad(op), '_blank')
}
async function enviarCaptura(e: FormEvent) {
@@ -893,6 +953,32 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
}
}
// Listas filtradas por los filtros avanzados; las usan el render por pestaña
// Y el botón de exportar (para que la exportación respete los filtros activos)
const leadsFiltrados = resumen
? aplicarFiltros(resumen.misLeads.filter((l) => l.fuente === 'lead'), filtros, {
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
origen: (l) => l.origen,
estado: (l) => l.estado,
fecha: (l) => l.updatedAt.slice(0, 10),
})
: []
const opsFiltradas = resumen
? aplicarFiltros(resumen.misOportunidades.items, filtros, {
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
desarrollo: (o) => o.desarrollo,
estado: (o) => o.etapa,
fecha: (o) => o.updatedAt.slice(0, 10),
})
: []
const apartadosFiltrados = resumen
? aplicarFiltros(resumen.misApartados, filtros, {
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
})
: []
const hoy = hoyLocal()
const iniciales = nombreVendedor
.split(' ')
@@ -1028,14 +1114,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{tabCrm !== 'tablero' && (
<div className="flex items-center gap-2">
<div className="flex-1">
<FiltrosAvanzados valor={filtros} onChange={setFiltros} />
<FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
</div>
<button
type="button"
onClick={() => {
if (tabCrm === 'leads') exportarLeads(resumen.misLeads.filter((l) => l.fuente === 'lead'))
else if (tabCrm === 'oportunidades') exportarOportunidades(resumen.misOportunidades.items)
else if (tabCrm === 'apartados') exportarApartados(resumen.misApartados)
if (tabCrm === 'leads') exportarLeads(leadsFiltrados)
else if (tabCrm === 'oportunidades') exportarOportunidades(opsFiltradas)
else if (tabCrm === 'apartados') exportarApartados(apartadosFiltrados)
}}
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-xs font-medium text-[#374151] shadow-sm transition-all hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#1E293B]"
>
@@ -1044,19 +1130,8 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</div>
)}
{tabCrm === 'leads' && (() => {
const leadsFiltrados = aplicarFiltros(
resumen.misLeads.filter((l) => l.fuente === 'lead'),
filtros,
{
texto: (l) => `${l.nombre} ${l.telefono} ${l.email}`,
origen: (l) => l.origen,
fecha: (l) => l.updatedAt.slice(0, 10),
},
)
return (
{tabCrm === 'leads' && (
<>
{leadsFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" />
) : vistaKanban ? (
@@ -1064,7 +1139,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={leadsFiltrados}
columnas={columnasLeads}
obtenerColumna={obtenerColumnaLead}
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoLead(id, estado, 'lead'), 'No se pudo actualizar')}
onCambiarEstado={(id, estado) => {
if (estado === 'DESCARTADO') {
const lead = resumen.misLeads.find((l) => l.id === id)
if (lead) setDescartando(lead)
return
}
if (estado === 'NUEVO') return
void ejecutar(() => cambiarEstadoLead(id, estado, undefined, 'lead'), 'No se pudo actualizar')
}}
/>
) : (
<ul className="space-y-2">
@@ -1083,22 +1166,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</ul>
)}
</>
)
})()}
)}
{tabCrm === 'oportunidades' && (() => {
const opsFiltradas = aplicarFiltros(
resumen.misOportunidades.items,
filtros,
{
texto: (o) => `${o.nombre} ${o.telefono} ${o.email}`,
desarrollo: (o) => o.desarrollo,
fecha: (o) => o.updatedAt.slice(0, 10),
},
)
return (
{tabCrm === 'oportunidades' && (
<>
{opsFiltradas.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" />
) : vistaKanban ? (
@@ -1106,7 +1177,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={opsFiltradas}
columnas={columnasOportunidades}
obtenerColumna={obtenerColumnaOp}
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')}
onCambiarEstado={(id, estado) => {
if (estado === 'PERDIDO') {
const op = resumen.misOportunidades.items.find((o) => o.id === id)
if (op) setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })
return
}
void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')
}}
/>
) : (
<ul className="space-y-2">
@@ -1120,7 +1198,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
}
onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op)
window.open(urlAfluenciaParaOportunidad(op), '_blank')
}}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
onVerDetalle={() => setLeadSeleccionado(op)}
@@ -1130,21 +1208,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</ul>
)}
</>
)
})()}
)}
{tabCrm === 'apartados' && (() => {
const apartadosFiltrados = aplicarFiltros(
resumen.misApartados,
filtros,
{
texto: (a) => a.nombre,
fecha: (a) => a.fechaLimite,
},
)
return (
{tabCrm === 'apartados' && (
<>
{apartadosFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin apartados asignados" />
) : vistaKanban ? (
@@ -1178,8 +1245,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</ul>
)}
</>
)
})()}
)}
{tabCrm === 'escrituracion' && (
<section aria-label="Escrituración">
@@ -1191,18 +1257,34 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
// Filtrar: solo apartados en fases de escrituración o Firma Cliente
// Solo fases activas (Cancelo y Escriturado son terminales)
// Fases activas de escrituración (excluye Cancelo y Escriturado que son terminales)
const apartadosEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
const enEscrituracion = (resumen.todosApartados || resumen.misApartados || []).filter((a) => {
const e = (a.estado ?? '').toUpperCase()
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('X ESCRITURAR')
return e.includes('FIRMA') || e.includes('VENTA') || e.includes('SEGUIMIENTO') || e.includes('INTEGRACION') || e.includes('X AUTORIZAR') || e.includes('AUTORIZADO') || e.includes('X ESCRITURAR')
})
const apartadosEscrituracion = aplicarFiltros(
enEscrituracion,
filtros,
{
texto: (a) => a.nombre,
estado: (a) => a.estado,
fecha: (a) => a.fechaLimite,
},
)
return apartadosEscrituracion.length === 0 ? (
<EstadoVacio mensaje="No hay apartados en escrituración" />
) : vistaKanban ? (
<KanbanView
items={resumen.misApartados}
items={apartadosEscrituracion}
columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion}
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')}
onCambiarEstado={(id, estado) => {
if (estado === 'Cancelo') {
const a = apartadosEscrituracion.find((x) => x.id === id)
if (a) setApartadoCancelando({ id: a.id, nombre: a.nombre })
return
}
void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')
}}
/>
) : (
<ul className="space-y-3">
@@ -1253,6 +1335,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<CalendarioCitas
actividades={resumen.misActividadesHoy}
oportunidades={resumen.misOportunidades.items}
onVerDetalle={(op) => setLeadSeleccionado(op)}
/>
</section>
)}
@@ -1552,15 +1635,22 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
)}
</main>
{descartando && (() => {
const lead = descartando
{descartando && (
<ModalDescarteGenerico
item={descartando}
titulo={descartando.fuente === 'oportunidad' ? 'Descartar oportunidad' : 'Descartar lead'}
enviando={enviando}
onCancelar={() => setDescartando(null)}
onConfirmar={(motivo, subcategoria, comentario) => {
const item = descartando
setDescartando(null)
void ejecutar(
() => descartarLead(lead.id, '', undefined, lead.fuente, undefined),
() => descartarLead(item.id, motivo, comentario, item.fuente, subcategoria),
'No se pudo descartar el lead',
)
return null
})()}
}}
/>
)}

View File

@@ -44,3 +44,16 @@ export async function crearRecordatorio(data: {
}
return (await res.json()) as Recordatorio
}
/** PATCH /api/recordatorios/:id/completar — marcar un recordatorio como completado */
export async function completarRecordatorio(id: string): Promise<Recordatorio> {
const res = await fetch(`${API_URL}/recordatorios/${encodeURIComponent(id)}/completar`, conAuth({
method: 'PATCH',
}))
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
}