Files
FRASA-CAS/apps/landing/src/components/PerfilContactoCompleto.tsx
Consult AS Technology 64b01246d6 feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday
Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
2026-09-11 06:52:37 +00:00

184 lines
7.8 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import { getContactoDetalle, type DatosContacto, 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: Partial<DatosContacto> = 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.telefono} />
<Field label="Estado" value={c.estado} />
<Field label="CURP" value={c.curp} />
<Field label="RFC" value={c.rfc} />
<Field label="Estado civil" value={c.estadoCivil} />
<Field label="Fecha nacimiento" value={c.fechaNacimiento} />
<Field label="Actividad económica" value={c.actividadEconomica} />
<Field label="Dirección" value={c.direccion} />
</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>
)
}