feat(ce): upgrade to v0.5.0 with contacts management, advanced settings and ui modernization
This commit is contained in:
222
company-explorer/frontend/src/components/ContactsTable.tsx
Normal file
222
company-explorer/frontend/src/components/ContactsTable.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Users, Search, ChevronLeft, ChevronRight, Upload,
|
||||
Mail, Building, Briefcase, User
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ContactsTableProps {
|
||||
apiBase: string
|
||||
onCompanyClick: (id: number) => void
|
||||
onContactClick: (companyId: number, contactId: number) => void // NEW
|
||||
}
|
||||
|
||||
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 [loading, setLoading] = useState(false)
|
||||
const limit = 50
|
||||
|
||||
// Import State
|
||||
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}`)
|
||||
.then(res => {
|
||||
setData(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(fetchContacts, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [page, search])
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importText) return
|
||||
setImportStatus("Parsing...")
|
||||
|
||||
try {
|
||||
// Simple CSV-ish parsing: Company, First, Last, Email, Job
|
||||
const lines = importText.split('\n').filter(l => l.trim())
|
||||
const contacts = lines.map(line => {
|
||||
const parts = line.split(/[;,|]+/).map(p => p.trim())
|
||||
// Expected: Company, First, Last, Email (optional)
|
||||
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 gap-2 max-w-xl">
|
||||
<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, companies, emails..."
|
||||
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>
|
||||
|
||||
<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 transition-colors"
|
||||
>
|
||||
<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"><Users 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>
|
||||
)}
|
||||
|
||||
{/* Data Grid */}
|
||||
<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>}
|
||||
|
||||
<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="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full text-slate-500 dark:text-slate-400">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<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 truncate max-w-[150px]" title={c.job_title}>
|
||||
{c.job_title || "No Title"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx("px-2 py-0.5 rounded text-[10px] font-bold border", c.status ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800" : "bg-slate-100 dark:bg-slate-800 text-slate-500 border-slate-200 dark:border-slate-700")}>
|
||||
{c.status || "No Status"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
|
||||
onClick={() => onCompanyClick(c.company_id)}
|
||||
>
|
||||
<Building className="h-3 w-3" />
|
||||
<span className="truncate font-medium">{c.company_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate">{c.email || "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
<span className="truncate">{c.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>Showing {data.length} of {total} contacts</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(p => Math.max(0, 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 className="px-2 py-1">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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user