249 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}