249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState, useCallback } from 'react';
|
||
import { formatPhone, relativeTime } from '@/lib/utils';
|
||
import type { ContactFilterParams } from '@/app/contacts/page';
|
||
|
||
interface Contact {
|
||
id: string;
|
||
phone: string;
|
||
name: string | null;
|
||
email: string | null;
|
||
status: string;
|
||
tags: string;
|
||
message_count: number;
|
||
last_contact: string;
|
||
first_contact: string;
|
||
bot_name: string | null;
|
||
assigned_bot_id: string | null;
|
||
fields_json: string;
|
||
}
|
||
|
||
interface ContactsTableProps {
|
||
filters: ContactFilterParams;
|
||
refreshKey: number;
|
||
selectedContactId: string | null;
|
||
onSelect: (id: string) => void;
|
||
}
|
||
|
||
const STATUS_CONFIG: Record<string, { label: string; dot: string; badgeClass: string }> = {
|
||
hot: { label: 'Hot Lead', dot: 'bg-red-500', badgeClass: 'badge-hot' },
|
||
warm: { label: 'Warm', dot: 'bg-orange-500', badgeClass: 'badge-warm' },
|
||
cold: { label: 'Cold', dot: 'bg-slate-400', badgeClass: 'badge-cold' },
|
||
new: { label: 'New', dot: 'bg-blue-500', badgeClass: 'badge-new' },
|
||
active: { label: 'Active', dot: 'bg-green-500', badgeClass: 'badge-active' },
|
||
closed: { label: 'Closed', dot: 'bg-gray-500', badgeClass: 'badge-closed' },
|
||
};
|
||
|
||
export function ContactsTable({ filters, refreshKey, selectedContactId, onSelect }: ContactsTableProps) {
|
||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [page, setPage] = useState(0);
|
||
const limit = 50;
|
||
|
||
const fetchContacts = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (filters.search) params.set('search', filters.search);
|
||
if (filters.status) params.set('status', filters.status);
|
||
if (filters.bot) params.set('bot', filters.bot);
|
||
params.set('limit', String(limit));
|
||
params.set('offset', String(page * limit));
|
||
|
||
const res = await fetch(`/api/contacts?${params.toString()}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setContacts(data.contacts);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch contacts:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [filters, page, refreshKey]);
|
||
|
||
useEffect(() => {
|
||
fetchContacts();
|
||
}, [fetchContacts]);
|
||
|
||
// Reset page when filters change
|
||
useEffect(() => {
|
||
setPage(0);
|
||
}, [filters]);
|
||
|
||
const parseTags = (tagsStr: string): string[] => {
|
||
try {
|
||
const parsed = JSON.parse(tagsStr);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
};
|
||
|
||
const getStatus = (status: string) => STATUS_CONFIG[status] || STATUS_CONFIG.new;
|
||
|
||
return (
|
||
<div className="card overflow-hidden">
|
||
{/* Table */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Name</th>
|
||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Phone Number</th>
|
||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Assigned Bot</th>
|
||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Status</th>
|
||
<th className="text-center px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Messages</th>
|
||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Last Contact</th>
|
||
<th className="text-left px-4 py-3 font-medium" style={{ color: 'var(--text-secondary)' }}>Tags</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-12 text-center" style={{ color: 'var(--text-muted)' }}>
|
||
<div className="flex items-center justify-center gap-2">
|
||
<svg className="animate-spin h-5 w-5" style={{ color: 'var(--accent-cyan)' }} viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||
</svg>
|
||
Loading contacts...
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : contacts.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-12 text-center" style={{ color: 'var(--text-muted)' }}>
|
||
<div className="flex flex-col items-center gap-2">
|
||
<svg className="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: 'var(--text-muted)' }}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
<p>No contacts found</p>
|
||
<p className="text-xs">Try adjusting your filters</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
contacts.map((contact) => {
|
||
const status = getStatus(contact.status);
|
||
const tags = parseTags(contact.tags);
|
||
const isSelected = contact.id === selectedContactId;
|
||
|
||
return (
|
||
<tr
|
||
key={contact.id}
|
||
onClick={() => onSelect(contact.id)}
|
||
className="cursor-pointer transition-colors duration-150"
|
||
style={{
|
||
borderLeft: isSelected ? '3px solid var(--accent-cyan)' : '3px solid transparent',
|
||
background: isSelected ? 'rgba(34, 211, 238, 0.05)' : undefined,
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isSelected) e.currentTarget.style.background = '#2d3748';
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = isSelected ? 'rgba(34, 211, 238, 0.05)' : '';
|
||
}}
|
||
>
|
||
{/* Name */}
|
||
<td className="px-4 py-3">
|
||
<span className="font-semibold text-white">
|
||
{contact.name || 'Unknown'}
|
||
</span>
|
||
</td>
|
||
|
||
{/* Phone */}
|
||
<td className="px-4 py-3 num" style={{ color: 'var(--text-secondary)' }}>
|
||
{formatPhone(contact.phone)}
|
||
</td>
|
||
|
||
{/* Assigned Bot */}
|
||
<td className="px-4 py-3" style={{ color: 'var(--text-secondary)' }}>
|
||
{contact.bot_name || (
|
||
<span style={{ color: 'var(--text-muted)' }}>Unassigned</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* Status */}
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
|
||
<span className={`badge ${status.badgeClass}`}>{status.label}</span>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Messages */}
|
||
<td className="px-4 py-3 text-center num" style={{ color: 'var(--text-secondary)' }}>
|
||
{contact.message_count}
|
||
</td>
|
||
|
||
{/* Last Contact */}
|
||
<td className="px-4 py-3" style={{ color: 'var(--text-secondary)' }}>
|
||
{contact.last_contact ? relativeTime(contact.last_contact) : '—'}
|
||
</td>
|
||
|
||
{/* Tags */}
|
||
<td className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-1">
|
||
{tags.slice(0, 3).map((tag, i) => (
|
||
<span
|
||
key={i}
|
||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||
style={{
|
||
background: 'rgba(34, 211, 238, 0.1)',
|
||
color: 'var(--accent-cyan)',
|
||
border: '1px solid rgba(34, 211, 238, 0.2)',
|
||
}}
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{tags.length > 3 && (
|
||
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||
+{tags.length - 3}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{!loading && contacts.length > 0 && (
|
||
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||
Showing {page * limit + 1}–{page * limit + contacts.length}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
disabled={page === 0}
|
||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}
|
||
>
|
||
Previous
|
||
</button>
|
||
<span className="text-xs num" style={{ color: 'var(--text-secondary)' }}>
|
||
Page {page + 1}
|
||
</span>
|
||
<button
|
||
onClick={() => setPage((p) => p + 1)}
|
||
disabled={contacts.length < limit}
|
||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||
style={{ background: 'var(--bg-hover)', color: 'var(--text-primary)' }}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|