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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
}}
|
||||||
})()}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user