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', 'Contrato Enkontrol',
'Firma Cliente', 'Firma Cliente',
'Cancelacion Apartado', 'Cancelacion Apartado',
'Venta',
'Seguimiento', 'Seguimiento',
'Integracion',
'Llamada Escrituras', 'Llamada Escrituras',
'X Escriturar', 'X Escriturar',
'Banco/Infonavit', 'Banco/Infonavit',
'X Autorizar', 'X Autorizar',
'Autorizado',
'Escriturado', 'Escriturado',
'Titulado',
'Cancelo', 'Cancelo',
] as const ] as const

View File

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

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import type { ActividadMiDia, OportunidadMiDia } from '../lib/mi-dia' 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 */ /** Evento unificado para el calendario */
interface EventoCalendario { interface EventoCalendario {
@@ -12,16 +13,56 @@ interface EventoCalendario {
detalle?: string detalle?: string
estado?: string estado?: string
idOriginal: string idOriginal: string
/** Oportunidad fuente (solo tipo 'oportunidad'): habilita llamar/WhatsApp/ver detalle */
item?: OportunidadMiDia
} }
interface Props { interface Props {
actividades: ActividadMiDia[] actividades: ActividadMiDia[]
oportunidades: OportunidadMiDia[] 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_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 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 { function obtenerSemanaBase(fecha: Date): Date {
const d = new Date(fecha) const d = new Date(fecha)
const diaSemana = d.getDay() const diaSemana = d.getDay()
@@ -48,7 +89,7 @@ function parsearFechaCita(fechaCita: string): { fecha: string; hora?: string } {
return { fecha: fechaCita } return { fecha: fechaCita }
} }
export default function CalendarioCitas({ actividades, oportunidades }: Props) { export default function CalendarioCitas({ actividades, oportunidades, onVerDetalle }: Props) {
const hoy = useMemo(() => { const hoy = useMemo(() => {
const d = new Date() const d = new Date()
d.setHours(0, 0, 0, 0) d.setHours(0, 0, 0, 0)
@@ -87,6 +128,7 @@ export default function CalendarioCitas({ actividades, oportunidades }: Props) {
detalle: op.etapa, detalle: op.etapa,
estado: op.etapa, estado: op.etapa,
idOriginal: op.id, idOriginal: op.id,
item: op,
}) })
} }
@@ -275,6 +317,9 @@ export default function CalendarioCitas({ actividades, oportunidades }: Props) {
</span> </span>
</p> </p>
)} )}
{eventoSeleccionado.item && (
<AccionesEvento item={eventoSeleccionado.item} onVerDetalle={onVerDetalle} />
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react' import { useCallback, useEffect, useState, type FormEvent } from 'react'
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react' import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia' import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia'
import { formatoFechaCorta } from '../lib/mi-dia' import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia'
import { listarRecordatorios, crearRecordatorio, type Recordatorio } from '../services/recordatorios' import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
import { getSesion } from '../services/auth'
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
@@ -22,6 +23,9 @@ function esOportunidad(item: LeadItem): item is OportunidadMiDia {
type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros' 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 }[] = [ const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
{ id: 'info', titulo: 'Información del cliente potencial', icono: User }, { id: 'info', titulo: 'Información del cliente potencial', icono: User },
{ id: 'cita', titulo: 'Reserve o actualice una cita', icono: Calendar }, { 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 }) { export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; onCerrar: () => void }) {
const esOp = esOportunidad(item) const esOp = esOportunidad(item)
const esLd = esLead(item) const esLd = esLead(item)
const vendedor = getSesion()?.usuario.nombre ?? ''
const [tab, setTab] = useState<TabId>('info') const [tab, setTab] = useState<TabId>('info')
// --- Notas --- // --- Notas ---
@@ -42,6 +47,8 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
const [nuevaNota, setNuevaNota] = useState('') const [nuevaNota, setNuevaNota] = useState('')
const [enviandoNota, setEnviandoNota] = useState(false) const [enviandoNota, setEnviandoNota] = useState(false)
const [busquedaNota, setBusquedaNota] = useState('') const [busquedaNota, setBusquedaNota] = useState('')
// --- Tareas ---
const [completandoTarea, setCompletandoTarea] = useState<string | null>(null)
const cargarNotas = useCallback(async () => { const cargarNotas = useCallback(async () => {
setCargandoNotas(true) setCargandoNotas(true)
@@ -60,13 +67,25 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
if (!nuevaNota.trim()) return if (!nuevaNota.trim()) return
setEnviandoNota(true) setEnviandoNota(true)
try { 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('') setNuevaNota('')
await cargarNotas() await cargarNotas()
} catch { setNotaError('No se pudo guardar.') } } catch { setNotaError('No se pudo guardar.') }
finally { setEnviandoNota(false) } 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(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onCerrar() } const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onCerrar() }
window.addEventListener('keydown', h) window.addEventListener('keydown', h)
@@ -107,6 +126,7 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{/* Contenido */} {/* Contenido */}
<div className="flex-1 overflow-y-auto px-6 py-5"> <div className="flex-1 overflow-y-auto px-6 py-5">
{tab === 'info' && ( {tab === 'info' && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoCampo etiqueta="Nombre" valor={item.nombre} /> <InfoCampo etiqueta="Nombre" valor={item.nombre} />
{(esLd || esOp) && item.telefono && <InfoCampo etiqueta="Teléfono" valor={item.telefono} />} {(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>
)} )}
</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' && ( {tab === 'cita' && (
@@ -144,13 +183,22 @@ export default function DetalleLeadModal({ item, onCerrar }: { item: LeadItem; o
{tab === 'tareas' && ( {tab === 'tareas' && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3> <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 ? ( {notasFiltradas.filter((n) => !n.completado).length === 0 ? (
<p className="text-sm text-[#94A3B8]">No hay tareas pendientes.</p> <p className="text-sm text-[#94A3B8]">No hay tareas pendientes.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{notasFiltradas.filter((n) => !n.completado).map((n) => ( {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"> <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="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> <span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</span>
</li> </li>

View File

@@ -5,6 +5,7 @@ export interface FiltrosCRM {
texto: string texto: string
desarrollo: string desarrollo: string
origen: string origen: string
estado: string
fechaDesde: string fechaDesde: string
fechaHasta: string fechaHasta: string
} }
@@ -16,14 +17,16 @@ const CLASE_SELECT =
interface Props { interface Props {
valor: FiltrosCRM valor: FiltrosCRM
onChange: (f: FiltrosCRM) => void 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) => const actualizar = (campo: keyof FiltrosCRM, v: string) =>
onChange({ ...valor, [campo]: v }) onChange({ ...valor, [campo]: v })
const hayActivos = 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 ( 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"> <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> </select>
</label> </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 */} {/* Fecha desde */}
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Desde</span> <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 && ( {hayActivos && (
<button <button
type="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]" 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 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. * Filtra un array de leads/opportunidades/apartados según los filtros activos.
@@ -113,6 +129,7 @@ export function aplicarFiltros(
texto?: (item: any) => string texto?: (item: any) => string
desarrollo?: (item: any) => string desarrollo?: (item: any) => string
origen?: (item: any) => string origen?: (item: any) => string
estado?: (item: any) => string
fecha?: (item: any) => string fecha?: (item: any) => string
}, },
): typeof items { ): typeof items {
@@ -134,6 +151,11 @@ export function aplicarFiltros(
if (accessor.origen(item) !== filtros.origen) return false 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 // Fecha desde
if (filtros.fechaDesde !== '' && accessor.fecha) { if (filtros.fechaDesde !== '' && accessor.fecha) {
const fecha = accessor.fecha(item) 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 [arrastrando, setArrastrando] = useState<string | null>(null)
const onDragStart = useCallback((e: React.DragEvent, itemId: string) => { const onDragStart = useCallback((e: React.DragEvent, itemId: string) => {
setArrastrando(itemId) e.dataTransfer.setData('text/plain', itemId)
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
setArrastrando(itemId)
}, []) }, [])
const onDragOver = useCallback((e: React.DragEvent) => { 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) => { const onDrop = useCallback((e: React.DragEvent, columnaId: string) => {
e.preventDefault() e.preventDefault()
const itemId = e.dataTransfer.getData('text/plain') const itemId = e.dataTransfer.getData('text/plain')
if (itemId && itemId !== arrastrando) {
onCambiarEstado(itemId, columnaId)
}
setArrastrando(null) 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 ( return (
<div className="flex gap-3 overflow-x-auto pb-4 scroll-smooth"> <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) // Labels reales de la columna status 'Desarrollo' del board Leads (verificados en Monday)
export const DESARROLLOS_LEAD = ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA'] as const 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) // Categorías del dropdown Motivo No Compra (vocabulario CRM FRASA, igual que el API)
export const MOTIVOS_NO_COMPRA = [ export const MOTIVOS_NO_COMPRA = [
'Precio', 'Precio',
@@ -165,6 +177,22 @@ export function componerTelefono(lada: ClaveLada, numero: string): string {
return `${prefijo} ${limpio}` 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) // 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 const FASES_OPORTUNIDAD = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'] as const
export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number] export type FaseOportunidad = (typeof FASES_OPORTUNIDAD)[number]

View File

@@ -10,10 +10,13 @@ import {
formatoFechaCorta, formatoFechaCorta,
hoyLocal, hoyLocal,
DESARROLLOS_LEAD, DESARROLLOS_LEAD,
ESTADOS_LEAD,
ETIQUETA_FASE_APARTADO, ETIQUETA_FASE_APARTADO,
ETIQUETA_FASE_ESCRITURACION, ETIQUETA_FASE_ESCRITURACION,
ETIQUETA_FASE_OPORTUNIDAD, ETIQUETA_FASE_OPORTUNIDAD,
FASES_APARTADO, FASES_APARTADO,
FASES_ESCRITURACION,
FASES_OPORTUNIDAD,
ORIGENES_LEAD, ORIGENES_LEAD,
SLOTS_DOCS_APARTADO, SLOTS_DOCS_APARTADO,
componerTelefono, componerTelefono,
@@ -21,6 +24,7 @@ import {
textoDiasRestantes, textoDiasRestantes,
urlAfluenciaParaOportunidad, urlAfluenciaParaOportunidad,
urlCotizadorParaOportunidad, urlCotizadorParaOportunidad,
urlWhatsApp,
type ApartadoMiDia, type ApartadoMiDia,
type ClaveLada, type ClaveLada,
type FaseApartado, type FaseApartado,
@@ -44,7 +48,7 @@ import {
import { guardarResumen, cargarResumenLocal } from '../services/offline-db' import { guardarResumen, cargarResumenLocal } from '../services/offline-db'
import { suscribirPush } from '../services/push' import { suscribirPush } from '../services/push'
import { exportarLeads, exportarOportunidades, exportarApartados } from '../services/exportar' 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 { getSesion } from '../services/auth'
import DashboardVendedor from '../components/DashboardVendedor' import DashboardVendedor from '../components/DashboardVendedor'
import ScoreBadge, { calcularScore } from '../components/ScoreBadge' 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]' 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) */ /** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */
function FilaOportunidad({ function FilaOportunidad({
@@ -146,7 +179,7 @@ function FilaOportunidad({
</p> </p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]"> <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.desarrollo ? ` · ${op.desarrollo}` : ''}
{op.modeloInteres ? ` · ${op.modeloInteres}` : ''} {op.modeloInteres ? ` · ${op.modeloInteres}` : ''}
{op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''} {op.unidadCodigo ? ` · ${op.unidadCodigo}` : ''}
@@ -269,8 +302,8 @@ function ModalDescarteGenerico({
onChange={(e) => setMotivo(e.target.value)} 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" 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) => ( {MOTIVOS_NO_COMPRA.map((m) => (
<option key={m} value={m}>{m}</option> <option key={m} value={m}>{m === 'Ubicacion' ? 'Ubicación' : m}</option>
))} ))}
</select> </select>
</div> </div>
@@ -351,9 +384,10 @@ function FilaApartado({
disabled={enviando} disabled={enviando}
onClick={() => { onClick={() => {
// Alta Enkontrol: abre el alta de cliente prellenada con // 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') { if (fase === 'Alta Enkontrol') {
window.location.href = `/client/?apartado=${apartado.id}` window.open(`/client/?apartado=${apartado.id}`, '_blank')
return return
} }
if (fase === 'Cancelacion Apartado') { if (fase === 'Cancelacion Apartado') {
@@ -598,7 +632,7 @@ function FilaLead({
</span> </span>
</p> </p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]"> <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.desarrollo ? ` · ${lead.desarrollo}` : ''}
{lead.origen ? ` · ${lead.origen}` : ''} {lead.origen ? ` · ${lead.origen}` : ''}
</p> </p>
@@ -662,87 +696,113 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
const [tabCrm, setTabCrm] = useState<TabCrm>('leads') const [tabCrm, setTabCrm] = useState<TabCrm>('leads')
const [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(null) const [leadSeleccionado, setLeadSeleccionado] = useState<LeadMiDia | OportunidadMiDia | ApartadoMiDia | null>(null)
const [apartadoCancelando, setApartadoCancelando] = useState<{ id: string; nombre: string } | 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 [offline, setOffline] = useState(!navigator.onLine)
const [offlineFecha, setOfflineFecha] = useState<string | null>(null) const [offlineFecha, setOfflineFecha] = useState<string | null>(null)
// Kanban toggle // Kanban toggle
const [vistaKanban, setVistaKanban] = useState(false) 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 = [ const columnasLeads = [
{ id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' }, { id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' },
{ id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' }, { 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: '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' }, { id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' },
] ]
const columnasOportunidades = [ const columnasOportunidades = [
{ id: 'AFLUENCIA', titulo: 'Afluencia', color: 'border-t-purple-500' }, { id: 'Afluencia', titulo: 'Afluencia', color: 'border-t-purple-500' },
{ id: 'COTIZADOR', titulo: 'Cotizador', color: 'border-t-blue-500' }, { id: 'Cotizador', titulo: 'Cotizador', color: 'border-t-blue-500' },
{ id: 'AGENDO CITA', titulo: 'Agendó Cita', color: 'border-t-green-500' }, { id: 'Agendo Cita', titulo: 'Agendó Cita', color: 'border-t-green-500' },
{ id: 'APARTO', titulo: 'Apartó', color: 'border-t-emerald-600' }, { 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' }, { id: 'PERDIDO', titulo: 'Perdido', color: 'border-t-red-500' },
] ]
const columnasApartados = [ const columnasApartados = [
{ id: 'PAGO APARTADO', titulo: 'Pago Apartado', color: 'border-t-blue-500' }, { id: 'Pago Apartado', titulo: 'Pago Apartado', color: 'border-t-blue-500' },
{ id: 'ALTA ENKONTROL', titulo: 'Alta Enkontrol', color: 'border-t-yellow-500' }, { id: 'Alta Enkontrol', titulo: 'Alta Enkontrol', color: 'border-t-yellow-500' },
{ id: 'ENTREGO DOCUMENTACION', titulo: 'Entregó Docs', color: 'border-t-orange-500' }, { id: 'Entrego Documentacion', titulo: 'Entregó Docs', color: 'border-t-orange-500' },
{ id: 'CITA CLIENTES', titulo: 'Cita Clientes', color: 'border-t-green-500' }, { id: 'Cita Clientes', titulo: 'Cita Clientes', color: 'border-t-green-500' },
{ id: 'CONTRATO ENKONTROL', titulo: 'Contrato Enkontrol', color: 'border-t-emerald-600' }, { id: 'Contrato Enkontrol', titulo: 'Contrato Enkontrol', color: 'border-t-emerald-600' },
{ id: 'FIRMA CLIENTE', titulo: 'Firma Cliente', color: 'border-t-accent' }, { id: 'Firma Cliente', titulo: 'Firma Cliente', color: 'border-t-accent' },
{ id: 'CANCELACION APARTADO', titulo: 'Cancelación', color: 'border-t-red-500' }, { id: 'Cancelacion Apartado', titulo: 'Cancelación', color: 'border-t-red-500' },
] ]
const columnasEscrituracion = [ const columnasEscrituracion = [
{ id: 'SEGUIMIENTO', titulo: 'Seguimiento', color: 'border-t-gray-400' }, { id: 'Venta', titulo: 'Venta', color: 'border-t-gray-400' },
{ id: 'VENTA', titulo: 'Venta', color: 'border-t-gray-400' }, { id: 'Seguimiento', titulo: 'Seguimiento', color: 'border-t-blue-500' },
{ id: 'SEGUIMIENTO', titulo: 'Seguimiento', color: 'border-t-blue-500' }, { id: 'Integracion', titulo: 'Integración', color: 'border-t-yellow-500' },
{ id: 'INTEGRACION', titulo: 'Integración', color: 'border-t-yellow-500' }, { id: 'X Autorizar', titulo: 'X Autorizar', color: 'border-t-orange-500' },
{ id: 'X AUTORIZAR', titulo: 'X Autorizar', color: 'border-t-orange-500' }, { id: 'Autorizado', titulo: 'Autorizado', color: 'border-t-green-500' },
{ id: 'AUTORIZADO', titulo: 'Autorizado', color: 'border-t-green-500' }, { id: 'X Escriturar', titulo: 'X Escriturar', color: 'border-t-emerald-500' },
{ id: 'X ESCRITURAR', titulo: 'X Escriturar', color: 'border-t-emerald-500' }, { id: 'Escriturado', titulo: 'Escriturado', color: 'border-t-emerald-600' },
{ id: 'ESCRITURADO', titulo: 'Escriturado', color: 'border-t-emerald-600' }, { id: 'Titulado', titulo: 'Titulado', color: 'border-t-purple-600' },
{ id: 'TITULADO', titulo: 'Titulado', color: 'border-t-purple-600' }, { id: 'Cancelo', titulo: 'Canceló', color: 'border-t-red-500' },
{ id: 'CANCELO', titulo: 'Canceló', color: 'border-t-red-500' },
] ]
const obtenerColumnaLead = (lead: LeadMiDia) => { const obtenerColumnaLead = (lead: LeadMiDia) => {
const e = (lead.estado ?? '').toUpperCase() const e = (lead.estado ?? '').toUpperCase()
if (e.includes('DESCART')) return 'DESCARTADO' 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('INTERES')) return 'INTERESADO'
if (e.includes('CONTACT')) return 'CONTACTADO' if (e.includes('CONTACT')) return 'CONTACTADO'
return 'NUEVO' return 'NUEVO'
} }
const obtenerColumnaOp = (op: OportunidadMiDia) => { const obtenerColumnaOp = (op: OportunidadMiDia) => {
const e = (op.etapa ?? '').toUpperCase() const e = (op.etapa ?? '').toUpperCase()
if (e.includes('APARTO')) return 'APARTO'
if (e.includes('PERDIDO') || e.includes('PERDI')) return 'PERDIDO' if (e.includes('PERDIDO') || e.includes('PERDI')) return 'PERDIDO'
if (e.includes('COTIZ')) return 'COTIZADOR' if (e.includes('ASISTIO')) return 'No Asistio'
if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDO CITA' if (e.includes('ABANDONO')) return 'Abandono'
return 'AFLUENCIA' 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 obtenerColumnaApartado = (a: ApartadoMiDia) => {
const e = (a.estado ?? '').toUpperCase() const e = (a.estado ?? '').toUpperCase()
if (e.includes('PAGO')) return 'PAGO APARTADO' if (e.includes('PAGO')) return 'Pago Apartado'
if (e.includes('ALTA') && e.includes('ENKONTROL')) return 'ALTA ENKONTROL' if (e.includes('ALTA') && e.includes('ENKONTROL')) return 'Alta Enkontrol'
if (e.includes('ENTREG') || e.includes('DOC')) return 'ENTREGO DOCUMENTACION' if (e.includes('ENTREG') || e.includes('DOC')) return 'Entrego Documentacion'
if (e.includes('CITA')) return 'CITA CLIENTES' if (e.includes('CITA')) return 'Cita Clientes'
if (e.includes('CONTRATO')) return 'CONTRATO ENKONTROL' if (e.includes('CONTRATO')) return 'Contrato Enkontrol'
if (e.includes('FIRMA')) return 'FIRMA CLIENTE' if (e.includes('FIRMA')) return 'Firma Cliente'
if (e.includes('CANCEL')) return 'CANCELACION APARTADO' if (e.includes('CANCEL')) return 'Cancelacion Apartado'
return 'PAGO APARTADO' return 'Pago Apartado'
} }
const obtenerColumnaEscrituracion = (a: ApartadoMiDia) => { const obtenerColumnaEscrituracion = (a: ApartadoMiDia) => {
const e = (a.estado ?? '').toUpperCase() const e = (a.estado ?? '').toUpperCase()
if (e.includes('VENTA')) return 'VENTA' if (e.includes('VENTA')) return 'Venta'
if (e.includes('SEGUIMIENTO')) return 'SEGUIMIENTO' if (e.includes('SEGUIMIENTO')) return 'Seguimiento'
if (e.includes('INTEGRACION')) return 'INTEGRACION' if (e.includes('INTEGRACION')) return 'Integracion'
if (e.includes('X AUTORIZAR')) return 'X AUTORIZAR' if (e.includes('X AUTORIZAR')) return 'X Autorizar'
if (e.includes('AUTORIZADO')) return 'AUTORIZADO' if (e.includes('AUTORIZADO')) return 'Autorizado'
if (e.includes('X ESCRITURAR')) return 'X ESCRITURAR' if (e.includes('X ESCRITURAR')) return 'X Escriturar'
if (e.includes('ESCRITURADO')) return 'ESCRITURADO' if (e.includes('ESCRITURADO')) return 'Escriturado'
if (e.includes('TITULADO')) return 'TITULADO' if (e.includes('TITULADO')) return 'Titulado'
if (e.includes('CANCEL')) return 'CANCELO' if (e.includes('CANCEL')) return 'Cancelo'
return 'VENTA' return 'Venta'
} }
@@ -854,7 +914,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
} catch { } catch {
toast.error('No se pudo actualizar la etapa en Monday; el cotizador se abrirá de todos modos.') 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) { 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 hoy = hoyLocal()
const iniciales = nombreVendedor const iniciales = nombreVendedor
.split(' ') .split(' ')
@@ -1028,14 +1114,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{tabCrm !== 'tablero' && ( {tabCrm !== 'tablero' && (
<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={setFiltros} /> <FiltrosAvanzados valor={filtros} onChange={actualizarFiltros} estados={estadosPorTab[tabCrm]} />
</div> </div>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
if (tabCrm === 'leads') exportarLeads(resumen.misLeads.filter((l) => l.fuente === 'lead')) if (tabCrm === 'leads') exportarLeads(leadsFiltrados)
else if (tabCrm === 'oportunidades') exportarOportunidades(resumen.misOportunidades.items) else if (tabCrm === 'oportunidades') exportarOportunidades(opsFiltradas)
else if (tabCrm === 'apartados') exportarApartados(resumen.misApartados) 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]" 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> </div>
)} )}
{tabCrm === 'leads' && (() => { {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 (
<> <>
{leadsFiltrados.length === 0 ? ( {leadsFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin leads asignados" /> <EstadoVacio mensaje="Sin leads asignados" />
) : vistaKanban ? ( ) : vistaKanban ? (
@@ -1064,7 +1139,15 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={leadsFiltrados} items={leadsFiltrados}
columnas={columnasLeads} columnas={columnasLeads}
obtenerColumna={obtenerColumnaLead} 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"> <ul className="space-y-2">
@@ -1083,22 +1166,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</ul> </ul>
)} )}
</> </>
) )}
})()}
{tabCrm === 'oportunidades' && (() => { {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 (
<> <>
{opsFiltradas.length === 0 ? ( {opsFiltradas.length === 0 ? (
<EstadoVacio mensaje="Sin oportunidades asignadas" /> <EstadoVacio mensaje="Sin oportunidades asignadas" />
) : vistaKanban ? ( ) : vistaKanban ? (
@@ -1106,7 +1177,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
items={opsFiltradas} items={opsFiltradas}
columnas={columnasOportunidades} columnas={columnasOportunidades}
obtenerColumna={obtenerColumnaOp} 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"> <ul className="space-y-2">
@@ -1120,7 +1198,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
} }
onCotizar={() => void cotizarOportunidad(op)} onCotizar={() => void cotizarOportunidad(op)}
onAfluencia={() => { onAfluencia={() => {
window.location.href = urlAfluenciaParaOportunidad(op) window.open(urlAfluenciaParaOportunidad(op), '_blank')
}} }}
onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })} onDescartar={() => setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })}
onVerDetalle={() => setLeadSeleccionado(op)} onVerDetalle={() => setLeadSeleccionado(op)}
@@ -1130,21 +1208,10 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</ul> </ul>
)} )}
</> </>
) )}
})()}
{tabCrm === 'apartados' && (() => { {tabCrm === 'apartados' && (
const apartadosFiltrados = aplicarFiltros(
resumen.misApartados,
filtros,
{
texto: (a) => a.nombre,
fecha: (a) => a.fechaLimite,
},
)
return (
<> <>
{apartadosFiltrados.length === 0 ? ( {apartadosFiltrados.length === 0 ? (
<EstadoVacio mensaje="Sin apartados asignados" /> <EstadoVacio mensaje="Sin apartados asignados" />
) : vistaKanban ? ( ) : vistaKanban ? (
@@ -1178,8 +1245,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
</ul> </ul>
)} )}
</> </>
) )}
})()}
{tabCrm === 'escrituracion' && ( {tabCrm === 'escrituracion' && (
<section aria-label="Escrituración"> <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 // Filtrar: solo apartados en fases de escrituración o Firma Cliente
// Solo fases activas (Cancelo y Escriturado son terminales) // Solo fases activas (Cancelo y Escriturado son terminales)
// Fases activas de escrituración (excluye Cancelo y Escriturado que 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() 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 ? ( return apartadosEscrituracion.length === 0 ? (
<EstadoVacio mensaje="No hay apartados en escrituración" /> <EstadoVacio mensaje="No hay apartados en escrituración" />
) : vistaKanban ? ( ) : vistaKanban ? (
<KanbanView <KanbanView
items={resumen.misApartados} items={apartadosEscrituracion}
columnas={columnasEscrituracion} columnas={columnasEscrituracion}
obtenerColumna={obtenerColumnaEscrituracion} 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"> <ul className="space-y-3">
@@ -1253,6 +1335,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
<CalendarioCitas <CalendarioCitas
actividades={resumen.misActividadesHoy} actividades={resumen.misActividadesHoy}
oportunidades={resumen.misOportunidades.items} oportunidades={resumen.misOportunidades.items}
onVerDetalle={(op) => setLeadSeleccionado(op)}
/> />
</section> </section>
)} )}
@@ -1552,15 +1635,22 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
)} )}
</main> </main>
{descartando && (() => { {descartando && (
const lead = 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) setDescartando(null)
void ejecutar( void ejecutar(
() => descartarLead(lead.id, '', undefined, lead.fuente, undefined), () => descartarLead(item.id, motivo, comentario, item.fuente, subcategoria),
'No se pudo descartar el lead', '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 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
}