diff --git a/apps/mi-dia/src/components/KanbanView.tsx b/apps/mi-dia/src/components/KanbanView.tsx index 1643d34..80cf6b2 100644 --- a/apps/mi-dia/src/components/KanbanView.tsx +++ b/apps/mi-dia/src/components/KanbanView.tsx @@ -1,131 +1,96 @@ import { useState, useCallback } from 'react' -import type { LeadMiDia } from '../lib/mi-dia' import ScoreBadge from './ScoreBadge' -/** Columnas del kanban */ -const COLUMNAS = [ - { id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' }, - { id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' }, - { id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' }, - { id: 'AGENDO', titulo: 'Agendo', color: 'border-t-green-500' }, - { id: 'DESCARTADO', titulo: 'Descartado', color: 'border-t-red-500' }, -] as const - -type ColumnaId = (typeof COLUMNAS)[number]['id'] - -function columnaLead(lead: LeadMiDia): ColumnaId { - const e = (lead.estado ?? '').toUpperCase() - if (e.includes('DESCART')) return 'DESCARTADO' - if (e.includes('AGENDO') || e.includes('CITA')) return 'AGENDO' - if (e.includes('INTERES')) return 'INTERESADO' - if (e.includes('CONTACT')) return 'CONTACTADO' - return 'NUEVO' +/** Columna genérica del kanban */ +interface ColumnaDef { + id: string + titulo: string + color: string } -interface KanbanViewProps { - leads: LeadMiDia[] - onCambiarEstado: (leadId: string, nuevoEstado: string, fuente?: 'lead' | 'oportunidad') => void +interface KanbanViewProps { + items: T[] + columnas: ColumnaDef[] + obtenerColumna: (item: T) => string + onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void + renderCard?: (item: T) => React.ReactNode } -export default function KanbanView({ leads, onCambiarEstado }: KanbanViewProps) { - const [dragId, setDragId] = useState(null) +export default function KanbanView({ + items, + columnas, + obtenerColumna, + onCambiarEstado, + renderCard, +}: KanbanViewProps) { + const [arrastrando, setArrastrando] = useState(null) - const onDragStart = useCallback((leadId: string) => { - setDragId(leadId) + const onDragStart = useCallback((e: React.DragEvent, itemId: string) => { + setArrastrando(itemId) + e.dataTransfer.effectAllowed = 'move' }, []) const onDragOver = useCallback((e: React.DragEvent) => { e.preventDefault() + e.dataTransfer.dropEffect = 'move' }, []) - const onDrop = useCallback( - (columnaId: ColumnaId) => { - if (!dragId) return - onCambiarEstado(dragId, columnaId, 'lead') - setDragId(null) - }, - [dragId, onCambiarEstado], - ) - - // Agrupar leads por columna - const grupos: Record = { - NUEVO: [], CONTACTADO: [], INTERESADO: [], AGENDO: [], DESCARTADO: [], - } - for (const lead of leads) { - const col = columnaLead(lead) - grupos[col].push(lead) - } + 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]) return ( -
- {COLUMNAS.map((col) => ( -
onDrop(col.id)} - > -
-

- {col.titulo} -

- - {grupos[col.id].length} - -
-
- {grupos[col.id].length === 0 ? ( -

Sin leads

- ) : ( - grupos[col.id].map((lead) => ( +
+ {columnas.map((col) => { + const itemsCol = items.filter((item) => obtenerColumna(item) === col.id) + return ( +
void onDrop(e, col.id)} + > +
+

+ {col.titulo} +

+ + {itemsCol.length} + +
+
+ {itemsCol.map((item) => (
onDragStart(lead.id)} - onDragEnd={() => setDragId(null)} - className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] cursor-grab transition-opacity sm:p-3 ${ - dragId === lead.id ? 'opacity-50' : '' - }`} + onDragStart={(e) => void onDragStart(e, item.id)} + className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''}`} > -
-

- {lead.nombre} -

- -
-

- {lead.telefono || 'Sin teléfono'} - {lead.origen ? ` · ${lead.origen}` : ''} -

+ {renderCard ? renderCard(item) : ( + <> +
+

{item.nombre}

+ {'score' in item && ).score) || 0} />} +
+ {item.telefono &&

{item.telefono}

} + {item.unidadCodigo &&

{item.unidadCodigo}

} + {item.pendienteSync && ⏳ sync} + + )}
- )) - )} + ))} + {itemsCol.length === 0 && ( +

Sin items

+ )} +
-
- ))} + ) + })}
) } - -/** Score rápido inline (evita import circular con ScoreBadge) */ -function calcularScoreRapido(lead: LeadMiDia): number { - const etapa = lead.estado ?? '' - const normalizada = etapa.toUpperCase() - let base = 20 - if (normalizada.includes('AGENDO') || normalizada.includes('CITA')) base = 60 - else if (normalizada.includes('INTERES')) base = 50 - else if (normalizada.includes('CONTACTAD')) base = 40 - else if (normalizada.includes('APARTO')) base = 90 - else if (normalizada.includes('DESCART') || normalizada.includes('NO ASIST')) base = 5 - let recencia = 0 - if (lead.updatedAt) { - const diffHoras = (Date.now() - new Date(lead.updatedAt).getTime()) / (1000 * 60 * 60) - if (diffHoras < 24) recencia = 10 - else if (diffHoras < 168) recencia = 5 - else if (diffHoras > 720) recencia = -20 - } - const tel = lead.telefono?.trim() ? 5 : 0 - const em = lead.email?.trim() ? 5 : 0 - const ori = lead.origen?.trim() ? 5 : 0 - return Math.max(0, Math.min(100, base + recencia + tel + em + ori)) -} diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 51135b0..333c710 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -665,6 +665,78 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones // Kanban toggle const [vistaKanban, setVistaKanban] = useState(false) + // Configuración de columnas por pestaña + const columnasLeads = [ + { id: 'NUEVO', titulo: 'Nuevo', color: 'border-t-gray-400' }, + { id: 'CONTACTADO', titulo: 'Contactado', color: 'border-t-blue-500' }, + { id: 'INTERESADO', titulo: 'Interesado', color: 'border-t-yellow-500' }, + { id: 'AGENDO', titulo: 'Agendo', color: 'border-t-green-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: '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' }, + ] + const columnasEscrituracion = [ + { id: 'SEGUIMIENTO', titulo: 'Seguimiento', color: 'border-t-gray-400' }, + { id: 'LLAMADA ESCRITURAS', titulo: 'Llamada Escrituras', color: 'border-t-blue-500' }, + { id: 'X ESCRITURAR', titulo: 'X Escriturar', color: 'border-t-yellow-500' }, + { id: 'BANCO/INFONAVIT', titulo: 'Banco/Infonavit', color: 'border-t-orange-500' }, + { id: 'X AUTORIZAR', titulo: 'X Autorizar', color: 'border-t-green-500' }, + { id: 'ESCRITURADO', titulo: 'Escriturado', color: 'border-t-emerald-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('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' + } + 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' + } + const obtenerColumnaEscrituracion = (a: ApartadoMiDia) => { + const e = (a.estado ?? '').toUpperCase() + if (e.includes('SEGUIMIENTO')) return 'SEGUIMIENTO' + if (e.includes('LLAMADA')) return 'LLAMADA ESCRITURAS' + if (e.includes('X ESCRIT') || e.includes('ESCRIT')) return 'X ESCRITURAR' + if (e.includes('BANCO') || e.includes('INFONAVIT')) return 'BANCO/INFONAVIT' + if (e.includes('X AUTORIZAR')) return 'X AUTORIZAR' + if (e.includes('ESCRITURADO')) return 'ESCRITURADO' + if (e.includes('CANCEL')) return 'CANCELO' + return 'SEGUIMIENTO' + } + // Timeline modal const [leadTimeline, setLeadTimeline] = useState<{ nombre: string; eventos: EventoLead[] } | null>(null) @@ -889,7 +961,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
)} -
+

{nombreVendedor}

{formatoFechaCorta(hoy)}

@@ -938,8 +1010,8 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones -
-
+
+
) : vistaKanban ? ( - void ejecutar(() => cambiarEstadoLead(leadId, nuevoEstado, undefined, fuente), 'No se pudo actualizar el lead') - } + items={leadsFiltrados} + columnas={columnasLeads} + obtenerColumna={obtenerColumnaLead} + onCambiarEstado={(id, estado) => void ejecutar(() => cambiarEstadoLead(id, estado, 'lead'), 'No se pudo actualizar')} /> ) : (
    @@ -1049,6 +1121,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{opsFiltradas.length === 0 ? ( + ) : vistaKanban ? ( + void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')} + /> ) : (
    {opsFiltradas.map((op) => ( @@ -1095,6 +1174,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
{apartadosFiltrados.length === 0 ? ( + ) : vistaKanban ? ( + void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')} + /> ) : (
    {apartadosFiltrados.map((a) => ( @@ -1133,6 +1219,13 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones

    {resumen.misApartados.length === 0 ? ( + ) : vistaKanban ? ( + void ejecutar(() => cambiarEstadoApartado(id, estado), 'No se pudo actualizar')} + /> ) : (
      {resumen.misApartados.map((apartado) => (