feat(ce): upgrade to v0.5.0 with contacts management, advanced settings and ui modernization
This commit is contained in:
@@ -1,15 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
createColumnHelper,
|
||||
} from '@tanstack/react-table'
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Play, Globe, AlertCircle, Search as SearchIcon, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Building, Search, ChevronLeft, ChevronRight, Upload,
|
||||
Globe, MapPin, Play, Search as SearchIcon, Loader2
|
||||
} from 'lucide-react'
|
||||
|
||||
type Company = {
|
||||
interface Company {
|
||||
id: number
|
||||
name: string
|
||||
city: string | null
|
||||
@@ -19,23 +15,28 @@ type Company = {
|
||||
industry_ai: string | null
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Company>()
|
||||
|
||||
interface CompanyTableProps {
|
||||
apiBase: string
|
||||
onRowClick: (companyId: number) => void // NEW PROP
|
||||
onRowClick: (companyId: number) => void
|
||||
refreshKey: number
|
||||
onImportClick: () => void
|
||||
}
|
||||
|
||||
export function CompanyTable({ apiBase, onRowClick }: CompanyTableProps) {
|
||||
export function CompanyTable({ apiBase, onRowClick, refreshKey, onImportClick }: CompanyTableProps) {
|
||||
const [data, setData] = useState<Company[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [search, setSearch] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [processingId, setProcessingId] = useState<number | null>(null)
|
||||
const limit = 50
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await axios.get(`${apiBase}/companies?limit=100`)
|
||||
const res = await axios.get(`${apiBase}/companies?skip=${page * limit}&limit=${limit}&search=${search}`)
|
||||
setData(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@@ -45,17 +46,17 @@ export function CompanyTable({ apiBase, onRowClick }: CompanyTableProps) {
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
}, [page, search, refreshKey])
|
||||
|
||||
const triggerDiscovery = async (id: number) => {
|
||||
setProcessingId(id)
|
||||
try {
|
||||
await axios.post(`${apiBase}/enrich/discover`, { company_id: id })
|
||||
// Optimistic update or wait for refresh? Let's refresh shortly after to see results
|
||||
setTimeout(fetchData, 2000)
|
||||
} catch (e) {
|
||||
alert("Discovery Error")
|
||||
setProcessingId(null)
|
||||
} finally {
|
||||
setProcessingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,140 +67,122 @@ export function CompanyTable({ apiBase, onRowClick }: CompanyTableProps) {
|
||||
setTimeout(fetchData, 2000)
|
||||
} catch (e) {
|
||||
alert("Analysis Error")
|
||||
setProcessingId(null)
|
||||
} finally {
|
||||
setProcessingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Company',
|
||||
cell: info => <span className="font-semibold text-white">{info.getValue()}</span>,
|
||||
}),
|
||||
columnHelper.accessor('city', {
|
||||
header: 'Location',
|
||||
cell: info => (
|
||||
<div className="text-slate-400 text-sm">
|
||||
{info.getValue() || '-'} <span className="text-slate-600">({info.row.original.country})</span>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor('website', {
|
||||
header: 'Website',
|
||||
cell: info => {
|
||||
const url = info.getValue()
|
||||
if (url && url !== "k.A.") {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noreferrer" className="flex items-center gap-1 text-blue-400 hover:underline text-sm">
|
||||
<Globe className="h-3 w-3" /> {new URL(url).hostname.replace('www.', '')}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return <span className="text-slate-600 text-sm italic">Not found</span>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('status', {
|
||||
header: 'Status',
|
||||
cell: info => {
|
||||
const s = info.getValue()
|
||||
return (
|
||||
<span className={clsx(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider",
|
||||
s === 'NEW' && "bg-slate-800 text-slate-400 border border-slate-700",
|
||||
s === 'DISCOVERED' && "bg-blue-500/10 text-blue-400 border border-blue-500/20",
|
||||
s === 'ENRICHED' && "bg-green-500/10 text-green-400 border border-green-500/20",
|
||||
)}>
|
||||
{s}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: info => {
|
||||
const c = info.row.original
|
||||
const isProcessing = processingId === c.id
|
||||
|
||||
if (isProcessing) {
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
}
|
||||
|
||||
// Action Logic
|
||||
if (c.status === 'NEW' || !c.website || c.website === "k.A.") {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); triggerDiscovery(c.id); }}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-300 rounded border border-slate-700 transition-colors"
|
||||
title="Search Website & Wiki"
|
||||
>
|
||||
<SearchIcon className="h-3 w-3" /> Find
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Ready for Analysis
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); triggerAnalysis(c.id); }}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 text-xs font-medium rounded border border-blue-500/20 transition-colors"
|
||||
title="Run AI Analysis"
|
||||
>
|
||||
<Play className="h-3 w-3 fill-current" /> Analyze
|
||||
</button>
|
||||
)
|
||||
}
|
||||
})
|
||||
], [processingId])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
if (loading && data.length === 0) return <div className="p-8 text-center text-slate-500">Loading companies...</div>
|
||||
|
||||
if (data.length === 0) return (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-block p-4 bg-slate-800 rounded-full mb-4">
|
||||
<AlertCircle className="h-8 w-8 text-slate-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white">No companies found</h3>
|
||||
<p className="text-slate-400 mt-2">Import a list to get started.</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<tr key={headerGroup.id} className="border-b border-slate-800 bg-slate-900/50">
|
||||
{headerGroup.headers.map(header => (
|
||||
<th key={header.id} className="p-4 text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{table.getRowModel().rows.map(row => (
|
||||
// Make row clickable
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => onRowClick(row.original.id)} // NEW: Row Click Handler
|
||||
className="hover:bg-slate-800/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<td key={cell.id} className="p-4 align-middle">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-col h-full bg-white dark:bg-slate-900 transition-colors">
|
||||
{/* Toolbar - Same style as Contacts */}
|
||||
<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="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..."
|
||||
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={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"
|
||||
>
|
||||
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid View - Same as Contacts */}
|
||||
<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}
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user