feat(kanban): vista kanban en todas las pestañas de Mi día

- KanbanView genérico: acepta items, columnas y extractor de columna
- Kanban en Mis leads (5 columnas: Nuevo/Contactado/Interesado/Agendo/Descartado)
- Kanban en Mis oportunidades (5 columnas: Afluencia/Cotizador/Agendó/Apartó/Perdido)
- Kanban en Mis apartados (7 columnas: Pago Apartado → Cancelación)
- Kanban en Escrituración (7 columnas: Seguimiento → Canceló)
- Drag & drop funcional en todas las pestañas
- Layout full-width en modo kanban
This commit is contained in:
2026-09-08 08:59:11 +00:00
parent d14749f389
commit febd636144
2 changed files with 172 additions and 114 deletions

View File

@@ -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<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }> {
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<string | null>(null)
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
items,
columnas,
obtenerColumna,
onCambiarEstado,
renderCard,
}: KanbanViewProps<T>) {
const [arrastrando, setArrastrando] = useState<string | null>(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<ColumnaId, LeadMiDia[]> = {
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 (
<div className="flex gap-4 overflow-x-auto scroll-smooth pb-4">
{COLUMNAS.map((col) => (
<div
key={col.id}
className={`flex min-w-[220px] flex-1 flex-col rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] border-t-4 ${col.color}`}
onDragOver={onDragOver}
onDrop={() => onDrop(col.id)}
>
<div className="flex items-center justify-between px-3 pt-3 pb-2 sm:px-4">
<h4 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{col.titulo}
</h4>
<span className="rounded-full bg-gray-200 dark:bg-[#334155] px-2 py-0.5 text-[10px] font-semibold text-[#64748B] dark:text-[#94A3B8]">
{grupos[col.id].length}
</span>
</div>
<div className="flex flex-1 flex-col gap-2 px-3 pb-3">
{grupos[col.id].length === 0 ? (
<p className="py-4 text-center text-[11px] text-[#94A3B8]">Sin leads</p>
) : (
grupos[col.id].map((lead) => (
<div className="flex gap-3 overflow-x-auto pb-4 scroll-smooth">
{columnas.map((col) => {
const itemsCol = items.filter((item) => obtenerColumna(item) === col.id)
return (
<div
key={col.id}
className={`flex min-w-[220px] flex-1 flex-col rounded-xl border border-[#E5E7EB] bg-[#F8FAFC] dark:border-[#334155] dark:bg-[#0F172A] border-t-4 ${col.color}`}
onDragOver={onDragOver}
onDrop={(e) => void onDrop(e, col.id)}
>
<div className="flex items-center justify-between px-3 pt-3 pb-2 sm:px-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
{col.titulo}
</h3>
<span className="rounded-full bg-[#E5E7EB] px-2 py-0.5 text-[10px] font-medium text-[#64748B] dark:bg-[#334155] dark:text-[#94A3B8]">
{itemsCol.length}
</span>
</div>
<div className="flex flex-1 flex-col gap-2 px-2 pb-3 sm:px-3">
{itemsCol.map((item) => (
<div
key={lead.id}
key={item.id}
draggable
onDragStart={() => 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' : ''}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0] truncate">
{lead.nombre}
</p>
<ScoreBadge score={calcularScoreRapido(lead)} />
</div>
<p className="mt-1 text-[11px] text-[#94A3B8] truncate">
{lead.telefono || 'Sin teléfono'}
{lead.origen ? ` · ${lead.origen}` : ''}
</p>
{renderCard ? renderCard(item) : (
<>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{item.nombre}</p>
{'score' in item && <ScoreBadge score={Number((item as Record<string, unknown>).score) || 0} />}
</div>
{item.telefono && <p className="mt-1 text-xs text-[#94A3B8]">{item.telefono}</p>}
{item.unidadCodigo && <p className="mt-0.5 text-xs text-[#94A3B8]">{item.unidadCodigo}</p>}
{item.pendienteSync && <span className="mt-1 inline-block rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">⏳ sync</span>}
</>
)}
</div>
))
)}
))}
{itemsCol.length === 0 && (
<p className="py-8 text-center text-xs text-[#94A3B8] dark:text-[#64748B]">Sin items</p>
)}
</div>
</div>
</div>
))}
)
})}
</div>
)
}
/** 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))
}