Files
FRASA-CAS/apps/landing/src/components/VistaContactos.tsx
Consult AS Technology c77b4c0628 feat(mejoras): 14 mejoras al CRM Frasa
Fase 1 - UI: dark mode, skeletons, toasts
Fase 2 - Filtros: avanzados Mi día, búsqueda global Ctrl+K
Fase 3 - Datos: export Excel, notas apartados, auditoría
Fase 4 - Dashboard: gráficas vendedor + métricas uso
Fase 5 - Responsive: tablets optimizadas
Fase 6 - PWA: IndexedDB offline, push notifications

Backend: 3 tablas nuevas, 6 services, 6 endpoints nuevos
721 API tests + fronts verdes.
2026-09-07 06:04:49 +00:00

155 lines
7.1 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { getContactos, type Contacto } from '../services/api'
import { ModalContacto } from './ModalContacto'
import { SkeletonList } from "./Skeleton"
import toast from "react-hot-toast"
interface Props {
onVolver: () => void
}
/**
* Vista interna "Contactos": lista del tablero Contactos de Monday (lo más
* reciente primero). Vive dentro de la landing (no hay router); se abre desde
* la tarjeta Contactos del grid. Click en un contacto → modal con toda su
* información, propiedad, quién lo atiende e historial de atención.
*/
export function VistaContactos({ onVolver }: Props) {
const [contactos, setContactos] = useState<Contacto[] | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
const [seleccionado, setSeleccionado] = useState<Contacto | null>(null)
const [busqueda, setBusqueda] = useState('')
/** Contactos filtrados por texto de búsqueda */
const contactosFiltrados = useMemo(() => {
if (!contactos) return null
if (busqueda.trim() === '') return contactos
const q = busqueda.toLowerCase()
return contactos.filter(
(c) =>
c.nombre.toLowerCase().includes(q) ||
(c.email && c.email.toLowerCase().includes(q)) ||
(c.telefono && c.telefono.includes(q)),
)
}, [contactos, busqueda])
const cargar = useCallback(() => {
setCargando(true)
setError('')
getContactos()
.then(setContactos)
.catch(() => { toast.error("No se pudieron cargar los contactos."); setError("No se pudieron cargar los contactos."); })
.finally(() => setCargando(false))
}, [])
useEffect(() => cargar(), [cargar])
return (
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-12 sm:px-6 sm:py-16">
<div className="flex items-center gap-4">
<button
type="button"
onClick={onVolver}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
&larr; Volver
</button>
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">Contactos</h1>
</div>
<section aria-label="Lista de contactos" className="mt-8">
{cargando ? (
<SkeletonList count={5} />
) : error ? (
<div>
<p role="alert" className="text-sm text-red-600">
{error}
</p>
<button
type="button"
onClick={cargar}
className="mt-3 rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Reintentar
</button>
</div>
) : !contactos || contactos.length === 0 ? (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">No hay contactos para mostrar.</p>
) : (
<>
<div className="mb-4">
<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"
/>
</div>
{contactosFiltrados && contactosFiltrados.length === 0 ? (
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Sin resultados para &ldquo;{busqueda}&rdquo;</p>
) : (
<>
{/* Mobile: cards */}
<div className="grid gap-3 md:hidden">
{contactosFiltrados?.map((contacto) => (
<button
key={contacto.id}
type="button"
onClick={() => setSeleccionado(contacto)}
className="flex flex-col gap-2 rounded-xl border border-[#E5E7EB] bg-white p-4 text-left shadow-sm transition-colors duration-100 hover:border-accent dark:border-[#1E293B] dark:bg-[#1E293B]"
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{contacto.nombre}</span>
{contacto.estado && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
{contacto.estado}
</span>
)}
</div>
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{contacto.telefono || 'Sin teléfono'}</span>
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">{contacto.email || 'Sin email'}</span>
<span className="text-xs font-medium text-accent">Ver detalle →</span>
</button>
))}
</div>
{/* Desktop: table-style list */}
<ul className="hidden divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow dark:border-[#1E293B] dark:bg-[#1E293B] md:block -sm">
{contactosFiltrados?.map((contacto) => (
<li key={contacto.id}>
<button
type="button"
onClick={() => setSeleccionado(contacto)}
className="flex w-full flex-wrap items-center gap-x-4 gap-y-1 px-6 py-4 text-left transition-colors duration-100 hover:bg-[#F8FAFC] dark:bg-[#0F172A]"
>
<span className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{contacto.nombre}</span>
{contacto.estado && (
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-accent">
{contacto.estado}
</span>
)}
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">{contacto.telefono || 'Sin teléfono'}</span>
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">{contacto.email || 'Sin email'}</span>
<span className="ml-auto text-xs font-medium text-accent">Ver detalle →</span>
</button>
</li>
))}
</ul>
</>
)}
</>
)}
</section>
{seleccionado && (
<ModalContacto
contactoId={seleccionado.id}
nombre={seleccionado.nombre}
onCerrar={() => setSeleccionado(null)}
/>
)}
</main>
)
}