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:
183
apps/landing/src/components/PerfilContactoCompleto.tsx
Normal file
183
apps/landing/src/components/PerfilContactoCompleto.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { getContactoDetalle, type DetalleContacto } from '../services/api'
|
||||
|
||||
const formatoFecha = (iso: string): string =>
|
||||
iso
|
||||
? new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
|
||||
const formatoMoneda = (n: number): string =>
|
||||
n > 0 ? n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }) : '—'
|
||||
|
||||
interface Props {
|
||||
contactoId: string
|
||||
nombre: string
|
||||
onCerrar: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal con perfil completo del contacto: datos personales, contacto,
|
||||
* propiedad, historial pagos, documentos, última actividad.
|
||||
*/
|
||||
export default function PerfilContactoCompleto({ contactoId, nombre, onCerrar }: Props) {
|
||||
const [detalle, setDetalle] = useState<DetalleContacto | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
try {
|
||||
setDetalle(await getContactoDetalle(contactoId))
|
||||
setError('')
|
||||
} catch {
|
||||
setError('No se pudo cargar el perfil del contacto')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [contactoId])
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
// Cerrar con Escape
|
||||
useEffect(() => {
|
||||
const alTeclear = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCerrar()
|
||||
}
|
||||
window.addEventListener('keydown', alTeclear)
|
||||
return () => window.removeEventListener('keydown', alTeclear)
|
||||
}, [onCerrar])
|
||||
|
||||
const c = detalle?.contacto ?? {}
|
||||
const prop = detalle?.propiedad ?? null
|
||||
const hist = detalle?.historial ?? []
|
||||
const rels = detalle?.relaciones ?? { leads: [], oportunidades: [], apartados: [] }
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-20 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCerrar() }}
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-[#E5E7EB] dark:border-[#334155] pb-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">Perfil del contacto</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{c['nombre'] ?? nombre}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCerrar}
|
||||
className="rounded-lg px-2 py-1.5 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cargando ? (
|
||||
<p className="mt-8 text-center text-sm text-[#94A3B8]">Cargando…</p>
|
||||
) : error ? (
|
||||
<p className="mt-8 text-center text-sm text-red-600">{error}</p>
|
||||
) : (
|
||||
<div className="mt-5 space-y-5">
|
||||
{/* Datos personales */}
|
||||
<Section titulo="Datos personales">
|
||||
<Field label="Email" value={c['Email']} />
|
||||
<Field label="Teléfono" value={c['Teléfono']} />
|
||||
<Field label="Estado" value={c['Estado']} />
|
||||
<Field label="CURP" value={c['CURP']} />
|
||||
<Field label="RFC" value={c['RFC']} />
|
||||
<Field label="Estado civil" value={c['Estado Civil']} />
|
||||
<Field label="Fecha nacimiento" value={c['Fecha Nacimiento']} />
|
||||
<Field label="Actividad económica" value={c['Actividad Economica']} />
|
||||
<Field label="Dirección" value={c['Dirección']} />
|
||||
</Section>
|
||||
|
||||
{/* Propiedad */}
|
||||
<Section titulo="Propiedad">
|
||||
{prop ? (
|
||||
<>
|
||||
<Field label="Unidad" value={prop.unidadCodigo} />
|
||||
<Field label="Desarrollo" value={prop.desarrollo} />
|
||||
<Field label="Estado" value={prop.estado} />
|
||||
<Field label="Precio final" value={formatoMoneda(prop.precioFinal)} />
|
||||
<Field label="Fecha compra" value={prop.fechaCompra?.slice(0, 10)} />
|
||||
<Field label="Atendido por" value={prop.atendidoPor} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-[#94A3B8]">Sin propiedad registrada</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Relaciones CRM */}
|
||||
{(rels.leads.length + rels.oportunidades.length + rels.apartados.length) > 0 && (
|
||||
<Section titulo="Relaciones CRM">
|
||||
{rels.leads.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Lead: {r.nombre}</span>
|
||||
<span className="rounded-full bg-gray-100 dark:bg-[#334155] px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:text-[#94A3B8]">{r.estado}</span>
|
||||
</div>
|
||||
))}
|
||||
{rels.oportunidades.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Oportunidad: {r.nombre}</span>
|
||||
<span className="rounded-full bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-400">{r.estado}</span>
|
||||
</div>
|
||||
))}
|
||||
{rels.apartados.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-[#111827] dark:text-[#E2E8F0]">Apartado: {r.nombre}</span>
|
||||
<span className="rounded-full bg-green-50 dark:bg-green-900/30 px-2 py-0.5 text-[10px] font-medium text-green-700 dark:text-green-400">{r.estado}</span>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Última actividad */}
|
||||
<Section titulo="Última actividad">
|
||||
{hist.length === 0 ? (
|
||||
<p className="text-xs text-[#94A3B8]">Sin actividad registrada</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-48 overflow-y-auto">
|
||||
{hist.slice(0, 10).map((ev, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3">
|
||||
<div className="mt-1 h-2 w-2 shrink-0 rounded-full bg-accent" />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[#111827] dark:text-[#E2E8F0]">{ev.detalle}</p>
|
||||
<p className="text-[11px] text-[#94A3B8]">
|
||||
{formatoFecha(ev.fechaISO)}
|
||||
{ev.usuario ? ` · ${ev.usuario}` : ''}
|
||||
{ev.fuente ? ` · ${ev.fuente}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ titulo, children }: { titulo: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] p-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{titulo}</h3>
|
||||
<div className="mt-3 space-y-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | number | null }) {
|
||||
if (!value && value !== 0) return null
|
||||
return (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-[#64748B] dark:text-[#94A3B8]">{label}</span>
|
||||
<span className="font-medium text-[#111827] dark:text-[#E2E8F0]">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { getContactos, type Contacto } from '../services/api'
|
||||
import { ModalContacto } from './ModalContacto'
|
||||
import PerfilContactoCompleto from './PerfilContactoCompleto'
|
||||
import { SkeletonList } from "./Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
import { conAuth, manejar401 } from '../services/auth'
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
@@ -43,6 +44,28 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
.finally(() => setCargando(false))
|
||||
}, [])
|
||||
|
||||
/** Exportar contactos como CSV */
|
||||
const exportarCSV = useCallback(async () => {
|
||||
try {
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
const res = await fetch(`${API_URL}/contactos/exportar?formato=csv`, conAuth())
|
||||
manejar401(res)
|
||||
if (!res.ok) throw new Error('No se pudo exportar')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `contactos_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('CSV descargado correctamente')
|
||||
} catch {
|
||||
toast.error('No se pudo exportar los contactos')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cargar(), [cargar])
|
||||
|
||||
return (
|
||||
@@ -78,14 +101,21 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay contactos para mostrar.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre, email o teléfono…"
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
className="flex-1 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void exportarCSV()}
|
||||
className="shrink-0 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
|
||||
>
|
||||
📥 Exportar CSV
|
||||
</button>
|
||||
</div>
|
||||
{contactosFiltrados && contactosFiltrados.length === 0 ? (
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para “{busqueda}”</p>
|
||||
@@ -143,7 +173,7 @@ export function VistaContactos({ onVolver }: Props) {
|
||||
</section>
|
||||
|
||||
{seleccionado && (
|
||||
<ModalContacto
|
||||
<PerfilContactoCompleto
|
||||
contactoId={seleccionado.id}
|
||||
nombre={seleccionado.nombre}
|
||||
onCerrar={() => setSeleccionado(null)}
|
||||
|
||||
Reference in New Issue
Block a user