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.
155 lines
7.1 KiB
TypeScript
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]"
|
|
>
|
|
← 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 “{busqueda}”</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>
|
|
)
|
|
}
|