- Fixed a critical in the company-explorer by forcing a database re-initialization with a new file (). This ensures the application code is in sync with the database schema. - Documented the schema mismatch incident and its resolution in MIGRATION_PLAN.md. - Restored and enhanced BUILDER_APPS_MIGRATION.md by recovering extensive, valuable content from the git history that was accidentally deleted. The guide now again includes detailed troubleshooting steps and code templates for common migration pitfalls.
216 lines
13 KiB
TypeScript
216 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import axios from 'axios'
|
|
import {
|
|
Users, Search, Upload, Mail, Building, LayoutGrid, List,
|
|
ChevronLeft, ChevronRight, X, ArrowDownUp
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
|
|
interface ContactsTableProps {
|
|
apiBase: string
|
|
onCompanyClick: (id: number) => void
|
|
onContactClick: (companyId: number, contactId: number) => void
|
|
}
|
|
|
|
export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: ContactsTableProps) {
|
|
const [data, setData] = useState<any[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(0)
|
|
const [search, setSearch] = useState("")
|
|
const [sortBy, setSortBy] = useState('name_asc')
|
|
const [loading, setLoading] = useState(false)
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
|
const limit = 50
|
|
|
|
const [isImportOpen, setIsImportOpen] = useState(false)
|
|
const [importText, setImportText] = useState("")
|
|
const [importStatus, setImportStatus] = useState<string | null>(null)
|
|
|
|
const fetchContacts = () => {
|
|
setLoading(true)
|
|
axios.get(`${apiBase}/contacts/all?skip=${page * limit}&limit=${limit}&search=${search}&sort_by=${sortBy}`)
|
|
.then(res => { setData(res.data.items); setTotal(res.data.total); })
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
const timeout = setTimeout(fetchContacts, 300)
|
|
return () => clearTimeout(timeout)
|
|
}, [page, search, sortBy])
|
|
|
|
const handleImport = async () => {
|
|
if (!importText) return
|
|
setImportStatus("Parsing...")
|
|
|
|
try {
|
|
const lines = importText.split('\n').filter(l => l.trim())
|
|
const contacts = lines.map(line => {
|
|
const parts = line.split(/[;,|]+/).map(p => p.trim())
|
|
if (parts.length < 3) return null
|
|
return {
|
|
company_name: parts[0],
|
|
first_name: parts[1],
|
|
last_name: parts[2],
|
|
email: parts[3] || null,
|
|
job_title: parts[4] || null
|
|
}
|
|
}).filter(Boolean)
|
|
|
|
if (contacts.length === 0) {
|
|
setImportStatus("Error: No valid contacts found. Format: Company, First, Last, Email")
|
|
return
|
|
}
|
|
|
|
setImportStatus(`Importing ${contacts.length} contacts...`)
|
|
const res = await axios.post(`${apiBase}/contacts/bulk`, { contacts })
|
|
setImportStatus(`Success! Added: ${res.data.added}, Created Companies: ${res.data.companies_created}, Skipped: ${res.data.skipped}`)
|
|
setImportText("")
|
|
setTimeout(() => {
|
|
setIsImportOpen(false)
|
|
setImportStatus(null)
|
|
fetchContacts()
|
|
}, 2000)
|
|
|
|
} catch (e) {
|
|
console.error(e)
|
|
setImportStatus("Import Failed.")
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-white dark:bg-slate-900 transition-colors">
|
|
{/* Toolbar */}
|
|
<div className="flex flex-col md:flex-row gap-4 p-4 border-b border-slate-200 dark:border-slate-800 items-center justify-between bg-slate-50 dark:bg-slate-950/50">
|
|
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold text-lg">
|
|
<Users className="h-5 w-5" />
|
|
<h2>All Contacts ({total})</h2>
|
|
</div>
|
|
|
|
<div className="flex flex-1 w-full md:w-auto items-center gap-2 max-w-2xl">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
|
<input type="text" placeholder="Search contacts..." className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
value={search} onChange={e => { setSearch(e.target.value); setPage(0); }}/>
|
|
</div>
|
|
<div className="relative flex items-center text-slate-700 dark:text-slate-300">
|
|
<ArrowDownUp className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
|
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
|
className="pl-8 pr-4 py-2 appearance-none bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
|
<option value="name_asc">Alphabetical</option>
|
|
<option value="created_desc">Newest First</option>
|
|
<option value="updated_desc">Last Modified</option>
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center bg-slate-200 dark:bg-slate-800 p-1 rounded-md text-slate-700 dark:text-slate-300">
|
|
<button onClick={() => setViewMode('grid')} className={clsx("p-1.5 rounded", viewMode === 'grid' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="Grid View"><LayoutGrid className="h-4 w-4" /></button>
|
|
<button onClick={() => setViewMode('list')} className={clsx("p-1.5 rounded", viewMode === 'list' && "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white")} title="List View"><List className="h-4 w-4" /></button>
|
|
</div>
|
|
<button onClick={() => setIsImportOpen(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm">
|
|
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Import Modal */}
|
|
{isImportOpen && (
|
|
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
|
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-lg border border-slate-200 dark:border-slate-800 flex flex-col max-h-[90vh]">
|
|
<div className="p-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
|
|
<h3 className="font-bold text-slate-900 dark:text-white">Bulk Import Contacts</h3>
|
|
<button onClick={() => setIsImportOpen(false)} className="text-slate-500 hover:text-red-500"><X className="h-5 w-5" /></button>
|
|
</div>
|
|
<div className="p-4 flex-1 overflow-y-auto">
|
|
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
|
Paste CSV data (no header). Format:<br/>
|
|
<code className="bg-slate-100 dark:bg-slate-800 px-1 py-0.5 rounded text-xs">Company Name, First Name, Last Name, Email, Job Title</code>
|
|
</p>
|
|
<textarea
|
|
className="w-full h-48 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-800 rounded p-2 text-xs font-mono text-slate-800 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
|
placeholder="Acme Corp, John, Doe, john@acme.com, CEO"
|
|
value={importText}
|
|
onChange={e => setImportText(e.target.value)}
|
|
/>
|
|
{importStatus && (
|
|
<div className={clsx("mt-2 text-sm font-bold p-2 rounded", importStatus.includes("Success") ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400")}>
|
|
{importStatus}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="p-4 border-t border-slate-200 dark:border-slate-800 flex justify-end gap-2">
|
|
<button onClick={() => setIsImportOpen(false)} className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white">Cancel</button>
|
|
<button onClick={handleImport} className="px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded hover:bg-blue-500">Run Import</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950/30">
|
|
{loading && <div className="p-4 text-center text-slate-500">Loading contacts...</div>}
|
|
|
|
{data.length === 0 && !loading ? (
|
|
<div className="p-12 text-center text-slate-500">
|
|
<Users className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
|
<p className="text-lg font-medium">No contacts found</p>
|
|
<p className="text-slate-400 mt-2">Import a list or create one manually to get started.</p>
|
|
</div>
|
|
) : viewMode === 'grid' ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
|
{data.map((c: any) => (
|
|
<div key={c.id} onClick={() => onContactClick(c.company_id, c.id)}
|
|
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4 border-l-slate-400">
|
|
<div className="font-bold text-slate-900 dark:text-white text-sm">{c.title} {c.first_name} {c.last_name}</div>
|
|
<div className="text-xs text-slate-500 dark:text-slate-400">{c.job_title || "No Title"}</div>
|
|
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
|
<div onClick={(e) => { e.stopPropagation(); onCompanyClick(c.company_id); }}
|
|
className="flex items-center gap-2 text-xs font-bold text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer">
|
|
<Building className="h-3 w-3" /> {c.company_name}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-slate-500"><Mail className="h-3 w-3" /> {c.email || "-"}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-800">
|
|
<thead className="bg-slate-100 dark:bg-slate-950/50">
|
|
<tr>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Name</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Company</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Email</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Role</th>
|
|
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 bg-white dark:bg-slate-900">
|
|
{data.map((c: any) => (
|
|
<tr key={c.id} onClick={() => onContactClick(c.company_id, c.id)} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 cursor-pointer">
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm font-medium text-slate-900 dark:text-white">{c.title} {c.first_name} {c.last_name}</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
|
|
<div onClick={(e) => { e.stopPropagation(); onCompanyClick(c.company_id); }}
|
|
className="font-bold text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer">
|
|
{c.company_name}
|
|
</div>
|
|
</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.email || '-' }</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.role || '-'}</td>
|
|
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.status || '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="p-3 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex justify-between items-center text-xs text-slate-500 dark:text-slate-400">
|
|
<span>{total} Contacts total</span>
|
|
<div className="flex gap-1 items-center">
|
|
<button disabled={page === 0} onClick={() => setPage(p => p - 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronLeft className="h-4 w-4" /></button>
|
|
<span>Page {page + 1}</span>
|
|
<button disabled={(page + 1) * limit >= total} onClick={() => setPage(p => p + 1)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"><ChevronRight className="h-4 w-4" /></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |