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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,74 +1130,66 @@ 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 ? (
|
||||
<KanbanView
|
||||
items={leadsFiltrados}
|
||||
columnas={columnasLeads}
|
||||
obtenerColumna={obtenerColumnaLead}
|
||||
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">
|
||||
{leadsFiltrados.map((lead) => (
|
||||
<FilaLead
|
||||
key={`${lead.fuente}-${lead.id}`}
|
||||
lead={lead}
|
||||
enviando={enviando}
|
||||
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)}
|
||||
acciones={acciones}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{leadsFiltrados.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin leads asignados" />
|
||||
) : vistaKanban ? (
|
||||
<KanbanView
|
||||
items={leadsFiltrados}
|
||||
columnas={columnasLeads}
|
||||
obtenerColumna={obtenerColumnaLead}
|
||||
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoLead(id, estado, 'lead'), 'No se pudo actualizar')}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{leadsFiltrados.map((lead) => (
|
||||
<FilaLead
|
||||
key={`${lead.fuente}-${lead.id}`}
|
||||
lead={lead}
|
||||
enviando={enviando}
|
||||
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)}
|
||||
acciones={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 (
|
||||
<>
|
||||
|
||||
{opsFiltradas.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin oportunidades asignadas" />
|
||||
) : vistaKanban ? (
|
||||
<KanbanView
|
||||
items={opsFiltradas}
|
||||
columnas={columnasOportunidades}
|
||||
obtenerColumna={obtenerColumnaOp}
|
||||
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{opsFiltradas.map((op) => (
|
||||
<FilaOportunidad
|
||||
{tabCrm === 'oportunidades' && (
|
||||
<>
|
||||
{opsFiltradas.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin oportunidades asignadas" />
|
||||
) : vistaKanban ? (
|
||||
<KanbanView
|
||||
items={opsFiltradas}
|
||||
columnas={columnasOportunidades}
|
||||
obtenerColumna={obtenerColumnaOp}
|
||||
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">
|
||||
{opsFiltradas.map((op) => (
|
||||
<FilaOportunidad
|
||||
key={op.id}
|
||||
op={op}
|
||||
enviando={enviando}
|
||||
@@ -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)}
|
||||
@@ -1129,57 +1207,45 @@ 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 (
|
||||
<>
|
||||
|
||||
{apartadosFiltrados.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin apartados asignados" />
|
||||
) : vistaKanban ? (
|
||||
<KanbanView
|
||||
items={apartadosFiltrados}
|
||||
columnas={columnasApartados}
|
||||
obtenerColumna={obtenerColumnaApartado}
|
||||
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{apartadosFiltrados.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
>
|
||||
<FilaApartado
|
||||
apartado={a}
|
||||
enviando={enviando}
|
||||
onVerDetalle={() => setLeadSeleccionado(a)}
|
||||
onFase={(fase) => {
|
||||
if (fase === 'Firma Cliente') {
|
||||
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
|
||||
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
|
||||
return
|
||||
}
|
||||
void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{tabCrm === 'apartados' && (
|
||||
<>
|
||||
{apartadosFiltrados.length === 0 ? (
|
||||
<EstadoVacio mensaje="Sin apartados asignados" />
|
||||
) : vistaKanban ? (
|
||||
<KanbanView
|
||||
items={apartadosFiltrados}
|
||||
columnas={columnasApartados}
|
||||
obtenerColumna={obtenerColumnaApartado}
|
||||
onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{apartadosFiltrados.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
>
|
||||
<FilaApartado
|
||||
apartado={a}
|
||||
enviando={enviando}
|
||||
onVerDetalle={() => setLeadSeleccionado(a)}
|
||||
onFase={(fase) => {
|
||||
if (fase === 'Firma Cliente') {
|
||||
// Cambiar etapa AÚNQUE luego cambie a pestaña Escrituración
|
||||
void ejecutar(() => cambiarEstadoApartado(a.id, fase).then(() => setTabCrm('escrituracion')), 'No se pudo actualizar el apartado')
|
||||
return
|
||||
}
|
||||
void ejecutar(() => cambiarEstadoApartado(a.id, fase), 'No se pudo actualizar el apartado')
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</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
|
||||
setDescartando(null)
|
||||
void ejecutar(
|
||||
() => descartarLead(lead.id, '', undefined, lead.fuente, undefined),
|
||||
'No se pudo descartar el lead',
|
||||
)
|
||||
return null
|
||||
})()}
|
||||
{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(item.id, motivo, comentario, item.fuente, subcategoria),
|
||||
'No se pudo descartar el lead',
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user