feat(ux): 17 mejoras de experiencia de usuario
Backend (10 endpoints nuevos): - Recordatorios personales (CRUD + tabla RecordatorioVendedor) - Lead scoring 0-100 (etapa+recencia+datos) - Dashboard mora (pagos vencidos, monto, días atraso) - Timeline de pagos (pagado/vence/futuro) - Perfil completo contacto (pagos+cotizaciones+docs+score) - Exportar contactos a CSV Frontend Mi día: - Resumen matutino (saludo+KPIs) - Historial visual lead (timeline) - Score badge 0-100 (verde/amarillo/rojo) - Vista kanban (drag & drop) Frontend Cobranza: - Dashboard mora (tabla+WhatsApp) - Timeline pagos visual (nodos coloreados) Frontend Alta Clientes: - Checklist documentos visual - Foto cliente (cámara) Frontend Landing: - Perfil contacto completo - Exportar contactos CSV Frontend Mi día (extras): - Comparativa mensual - KPIs dashboard (leads hoy, ventas, conversión) 806 tests, 5 apps construidas.
This commit is contained in:
131
apps/mi-dia/src/components/KanbanView.tsx
Normal file
131
apps/mi-dia/src/components/KanbanView.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
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'
|
||||
}
|
||||
|
||||
interface KanbanViewProps {
|
||||
leads: LeadMiDia[]
|
||||
onCambiarEstado: (leadId: string, nuevoEstado: string, fuente?: 'lead' | 'oportunidad') => void
|
||||
}
|
||||
|
||||
export default function KanbanView({ leads, onCambiarEstado }: KanbanViewProps) {
|
||||
const [dragId, setDragId] = useState<string | null>(null)
|
||||
|
||||
const onDragStart = useCallback((leadId: string) => {
|
||||
setDragId(leadId)
|
||||
}, [])
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNAS.map((col) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className={`flex min-w-[260px] 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-4 pt-3 pb-2">
|
||||
<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
|
||||
key={lead.id}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(lead.id)}
|
||||
onDragEnd={() => setDragId(null)}
|
||||
className={`rounded-lg border border-[#E5E7EB] bg-white p-3 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] cursor-grab transition-opacity ${
|
||||
dragId === lead.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>
|
||||
</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))
|
||||
}
|
||||
Reference in New Issue
Block a user