fix(ce): Resolve database schema mismatch and restore docs
- 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.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Building, Search, ChevronLeft, ChevronRight, Upload,
|
||||
Globe, MapPin, Play, Search as SearchIcon, Loader2
|
||||
Building, Search, Upload, Globe, MapPin, Play, Search as SearchIcon, Loader2,
|
||||
LayoutGrid, List, ChevronLeft, ChevronRight, ArrowDownUp
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface Company {
|
||||
id: number
|
||||
@@ -13,6 +14,8 @@ interface Company {
|
||||
website: string | null
|
||||
status: string
|
||||
industry_ai: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface CompanyTableProps {
|
||||
@@ -27,160 +30,168 @@ export function CompanyTable({ apiBase, onRowClick, refreshKey, onImportClick }:
|
||||
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 [processingId, setProcessingId] = useState<number | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const limit = 50
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await axios.get(`${apiBase}/companies?skip=${page * limit}&limit=${limit}&search=${search}`)
|
||||
const res = await axios.get(`${apiBase}/companies?skip=${page * limit}&limit=${limit}&search=${search}&sort_by=${sortBy}`)
|
||||
setData(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch companies", e)
|
||||
}
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [page, search, refreshKey])
|
||||
const timer = setTimeout(fetchData, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [page, search, refreshKey, sortBy])
|
||||
|
||||
const triggerDiscovery = async (id: number) => {
|
||||
setProcessingId(id)
|
||||
setProcessingId(id);
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrich/discover`, { company_id: id })
|
||||
setTimeout(fetchData, 2000)
|
||||
} catch (e) {
|
||||
alert("Discovery Error")
|
||||
} finally {
|
||||
setProcessingId(null)
|
||||
}
|
||||
await axios.post(`${apiBase}/enrich/discover`, { company_id: id });
|
||||
setTimeout(fetchData, 2000);
|
||||
} catch (e) { alert("Discovery Error"); }
|
||||
finally { setProcessingId(null); }
|
||||
}
|
||||
|
||||
const triggerAnalysis = async (id: number) => {
|
||||
setProcessingId(id)
|
||||
setProcessingId(id);
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrich/analyze`, { company_id: id })
|
||||
setTimeout(fetchData, 2000)
|
||||
} catch (e) {
|
||||
alert("Analysis Error")
|
||||
} finally {
|
||||
setProcessingId(null)
|
||||
}
|
||||
await axios.post(`${apiBase}/enrich/analyze`, { company_id: id });
|
||||
setTimeout(fetchData, 2000);
|
||||
} catch (e) { alert("Analysis Error"); }
|
||||
finally { setProcessingId(null); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-slate-900 transition-colors">
|
||||
{/* Toolbar - Same style as Contacts */}
|
||||
{/* 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">
|
||||
<Building className="h-5 w-5" />
|
||||
<h2>Companies ({total})</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 w-full md:w-auto gap-2 max-w-xl">
|
||||
<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 companies, cities, industries..."
|
||||
<input type="text" placeholder="Search companies..."
|
||||
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); }}
|
||||
/>
|
||||
value={search} onChange={e => { setSearch(e.target.value); setPage(0); }}/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onImportClick}
|
||||
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"
|
||||
>
|
||||
<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={onImportClick} 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>
|
||||
|
||||
{/* Grid View - Same as Contacts */}
|
||||
|
||||
{/* 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 companies...</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) => (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => onRowClick(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"
|
||||
style={{ borderLeftColor: c.status === 'ENRICHED' ? '#22c55e' : c.status === 'DISCOVERED' ? '#3b82f6' : '#94a3b8' }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold text-slate-900 dark:text-white text-sm truncate" title={c.name}>
|
||||
{c.name}
|
||||
{data.length === 0 && !loading ? (
|
||||
<div className="p-12 text-center text-slate-500">
|
||||
<Building className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">No companies 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) => (
|
||||
<div key={c.id} onClick={() => onRowClick(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"
|
||||
style={{ borderLeftColor: c.status === 'ENRICHED' ? '#22c55e' : c.status === 'DISCOVERED' ? '#3b82f6' : '#94a3b8' }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold text-slate-900 dark:text-white text-sm truncate" title={c.name}>{c.name}</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
||||
{c.city && c.country ? (<><MapPin className="h-3 w-3" /> {c.city} <span className="text-slate-400">({c.country})</span></>) : (<span className="italic opacity-50">-</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-500 dark:text-slate-400 font-medium">
|
||||
<MapPin className="h-3 w-3" /> {c.city || 'Unknown'}, {c.country}
|
||||
<div className="flex gap-1 ml-2">
|
||||
{processingId === c.id ? <Loader2 className="h-4 w-4 animate-spin text-blue-500" /> : c.status === 'NEW' || !c.website || c.website === 'k.A.' ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); triggerDiscovery(c.id); }} className="p-1.5 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded hover:bg-blue-600 hover:text-white transition-colors"><SearchIcon className="h-3.5 w-3.5" /></button>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); triggerAnalysis(c.id); }} className="p-1.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded hover:bg-blue-600 hover:text-white transition-colors"><Play className="h-3.5 w-3.5 fill-current" /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2">
|
||||
{processingId === c.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
) : c.status === 'NEW' || !c.website || c.website === 'k.A.' ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); triggerDiscovery(c.id); }}
|
||||
className="p-1.5 bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded hover:bg-blue-600 hover:text-white transition-colors"
|
||||
>
|
||||
<SearchIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); triggerAnalysis(c.id); }}
|
||||
className="p-1.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded hover:bg-blue-600 hover:text-white transition-colors"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
</button>
|
||||
)}
|
||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||
{c.website && c.website !== "k.A." ? (
|
||||
<div className="flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400 font-medium truncate">
|
||||
<Globe className="h-3 w-3" />
|
||||
<span>{new URL(c.website).hostname.replace('www.', '')}</span>
|
||||
</div>
|
||||
) : (<div className="text-xs text-slate-400 italic">No website found</div>)}
|
||||
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-wider truncate">{c.industry_ai || "Industry Pending"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||
{c.website && c.website !== "k.A." ? (
|
||||
<div className="flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400 font-medium truncate">
|
||||
<Globe className="h-3 w-3" />
|
||||
<span>{new URL(c.website).hostname.replace('www.', '')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400 italic">No website found</div>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-wider truncate">
|
||||
{c.industry_ai || "Industry Pending"}
|
||||
</div>
|
||||
</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">Company</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Location</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">Website</th>
|
||||
<th scope="col" className="px-3 py-3.5 text-left text-sm font-semibold text-slate-900 dark:text-white">AI Industry</th>
|
||||
<th scope="col" className="relative px-3 py-3.5"><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-800 bg-white dark:bg-slate-900">
|
||||
{data.map((c) => (
|
||||
<tr key={c.id} onClick={() => onRowClick(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.name}</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
{c.city && c.country ? `${c.city}, (${c.country})` : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-blue-600 dark:text-blue-400">
|
||||
{c.website && c.website !== "k.A." ? <a href={c.website} target="_blank" rel="noreferrer">{new URL(c.website).hostname.replace('www.', '')}</a> : 'n/a'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-slate-500 dark:text-slate-400">{c.industry_ai || 'Pending'}</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
{processingId === c.id ? <Loader2 className="h-4 w-4 animate-spin text-blue-500" /> : c.status === 'NEW' || !c.website || c.website === 'k.A.' ? (
|
||||
<button onClick={(e) => { e.stopPropagation(); triggerDiscovery(c.id); }} className="text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400"><SearchIcon className="h-4 w-4" /></button>
|
||||
) : (
|
||||
<button onClick={(e) => { e.stopPropagation(); triggerAnalysis(c.id); }} className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"><Play className="h-4 w-4 fill-current" /></button>
|
||||
)}
|
||||
</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} Companies total</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 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>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Users, Search, ChevronLeft, ChevronRight, Upload,
|
||||
Mail, Building, Briefcase, User
|
||||
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 // NEW
|
||||
onContactClick: (companyId: number, contactId: number) => void
|
||||
}
|
||||
|
||||
export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: ContactsTableProps) {
|
||||
@@ -17,39 +17,35 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
||||
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
|
||||
|
||||
// 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)
|
||||
})
|
||||
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])
|
||||
}, [page, search, sortBy])
|
||||
|
||||
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],
|
||||
@@ -90,34 +86,38 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
||||
<h2>All Contacts ({total})</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 w-full md:w-auto gap-2 max-w-xl">
|
||||
<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, 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); }}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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"><Users className="h-5 w-5" /></button>
|
||||
<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">
|
||||
@@ -144,77 +144,71 @@ export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: Conta
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Grid */}
|
||||
{/* 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>}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
||||
{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="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 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>
|
||||
</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>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>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { X, ExternalLink, Bot, Briefcase, Calendar, Globe, Users, DollarSign, MapPin, Tag, RefreshCw as RefreshCwIcon, Search as SearchIcon, Pencil, Check, Download, Clock } from 'lucide-react'
|
||||
import { X, ExternalLink, Bot, Briefcase, Calendar, Globe, Users, DollarSign, MapPin, Tag, RefreshCw as RefreshCwIcon, Search as SearchIcon, Pencil, Check, Download, Clock, Lock, Unlock } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { ContactsManager, Contact } from './ContactsManager'
|
||||
|
||||
@@ -204,6 +204,16 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
}
|
||||
}
|
||||
|
||||
const handleLockToggle = async (sourceType: string, currentLockStatus: boolean) => {
|
||||
if (!companyId) return
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrichment/${companyId}/${sourceType}/lock?locked=${!currentLockStatus}`)
|
||||
fetchData(true) // Silent refresh
|
||||
} catch (e) {
|
||||
console.error("Lock toggle failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddContact = async (contact: Contact) => {
|
||||
if (!companyId) return
|
||||
try {
|
||||
@@ -397,23 +407,39 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
<Briefcase className="h-3 w-3" />
|
||||
</div>
|
||||
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Official Legal Data</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{scrapeDate && (
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" /> {new Date(scrapeDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
{!isEditingImpressum ? (
|
||||
<button
|
||||
onClick={() => { setImpressumUrlInput(""); setIsEditingImpressum(true); }}
|
||||
className="p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
||||
title="Set Impressum URL Manually"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 animate-in fade-in zoom-in duration-200">
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{scrapeDate && (
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" /> {new Date(scrapeDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lock Button for Impressum */}
|
||||
{scrapeEntry && (
|
||||
<button
|
||||
onClick={() => handleLockToggle('website_scrape', scrapeEntry.is_locked || false)}
|
||||
className={clsx(
|
||||
"p-1 rounded transition-colors",
|
||||
scrapeEntry.is_locked
|
||||
? "text-green-600 dark:text-green-400 hover:text-green-700"
|
||||
: "text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
||||
)}
|
||||
title={scrapeEntry.is_locked ? "Data Locked (Safe from auto-overwrite)" : "Unlocked (Auto-overwrite enabled)"}
|
||||
>
|
||||
{scrapeEntry.is_locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isEditingImpressum ? (
|
||||
<button
|
||||
onClick={() => { setImpressumUrlInput(""); setIsEditingImpressum(true); }}
|
||||
className="p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
||||
title="Set Impressum URL Manually"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
) : ( <div className="flex items-center gap-1 animate-in fade-in zoom-in duration-200">
|
||||
<button
|
||||
onClick={handleImpressumOverride}
|
||||
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
||||
@@ -510,22 +536,71 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
<Globe className="h-4 w-4" /> Company Profile (Wikipedia)
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{wikiDate && (
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-1 mr-2">
|
||||
<Clock className="h-3 w-3" /> {new Date(wikiDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
{!isEditingWiki ? (
|
||||
<button
|
||||
onClick={() => { setWikiUrlInput(wiki?.url || ""); setIsEditingWiki(true); }}
|
||||
className="p-1 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title="Edit / Override URL"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{wikiDate && (
|
||||
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-1 mr-2">
|
||||
|
||||
<Clock className="h-3 w-3" /> {new Date(wikiDate).toLocaleDateString()}
|
||||
|
||||
</div>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Lock Button for Wiki */}
|
||||
|
||||
{wikiEntry && (
|
||||
|
||||
<button
|
||||
|
||||
onClick={() => handleLockToggle('wikipedia', wikiEntry.is_locked || false)}
|
||||
|
||||
className={clsx(
|
||||
|
||||
"p-1 rounded transition-colors mr-1",
|
||||
|
||||
wikiEntry.is_locked
|
||||
|
||||
? "text-green-600 dark:text-green-400 hover:text-green-700"
|
||||
|
||||
: "text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
||||
|
||||
)}
|
||||
|
||||
title={wikiEntry.is_locked ? "Wiki Data Locked" : "Wiki Data Unlocked"}
|
||||
|
||||
>
|
||||
|
||||
{wikiEntry.is_locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||
|
||||
</button>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{!isEditingWiki ? (
|
||||
|
||||
<button
|
||||
|
||||
onClick={() => { setWikiUrlInput(wiki?.url || ""); setIsEditingWiki(true); }}
|
||||
|
||||
className="p-1 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
|
||||
title="Edit / Override URL"
|
||||
|
||||
>
|
||||
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
|
||||
</button>
|
||||
|
||||
) : ( <div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleWikiOverride}
|
||||
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user