feat(ce): upgrade to v0.5.0 with contacts management, advanced settings and ui modernization

This commit is contained in:
2026-01-15 09:23:58 +00:00
parent 2b7c072ddc
commit 4b815c6510
16 changed files with 2794 additions and 828 deletions

View File

@@ -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>
)
}
}

View File

@@ -0,0 +1,317 @@
import { useState, useEffect } from 'react'
import { Users, Star, Mail, User, Activity, Plus, X, Save } from 'lucide-react'
import clsx from 'clsx'
export type ContactRole = 'Operativer Entscheider' | 'Infrastruktur-Verantwortlicher' | 'Wirtschaftlicher Entscheider' | 'Innovations-Treiber'
export type ContactStatus =
| '' // Leer
// Manual
| 'Soft Denied' | 'Bounced' | 'Redirect' | 'Interested' | 'Hard denied'
// Auto
| 'Init' | '1st Step' | '2nd Step' | 'Not replied'
export interface Contact {
id?: number
gender: 'männlich' | 'weiblich'
title: string
first_name: string
last_name: string
email: string
job_title: string
language: 'De' | 'En'
role: ContactRole
status: ContactStatus
is_primary: boolean
}
interface ContactsManagerProps {
contacts?: Contact[]
initialContactId?: number | null // NEW
onAddContact?: (contact: Contact) => void
onEditContact?: (contact: Contact) => void
}
export function ContactsManager({ contacts = [], initialContactId, onAddContact, onEditContact }: ContactsManagerProps) {
const [editingContact, setEditingContact] = useState<Contact | null>(null)
const [isFormOpen, setIsFormOpen] = useState(false)
// Auto-open edit form if initialContactId is provided
useEffect(() => {
if (initialContactId && contacts.length > 0) {
const contact = contacts.find(c => c.id === initialContactId)
if (contact) {
setEditingContact({ ...contact })
setIsFormOpen(true)
}
}
}, [initialContactId, contacts])
const roleColors: Record<ContactRole, string> = {
'Operativer Entscheider': 'text-blue-400 border-blue-400/30 bg-blue-900/20',
'Infrastruktur-Verantwortlicher': 'text-orange-400 border-orange-400/30 bg-orange-900/20',
'Wirtschaftlicher Entscheider': 'text-green-400 border-green-400/30 bg-green-900/20',
'Innovations-Treiber': 'text-purple-400 border-purple-400/30 bg-purple-900/20'
}
const statusColors: Record<string, string> = {
'': 'text-slate-600 italic',
'Soft Denied': 'text-slate-400',
'Bounced': 'text-red-500',
'Redirect': 'text-yellow-500',
'Interested': 'text-green-500',
'Hard denied': 'text-red-700',
'Init': 'text-slate-300',
'1st Step': 'text-blue-300',
'2nd Step': 'text-blue-400',
'Not replied': 'text-slate-500',
}
const handleAddNew = () => {
setEditingContact({
gender: 'männlich',
title: '',
first_name: '',
last_name: '',
email: '',
job_title: '',
language: 'De',
role: 'Operativer Entscheider',
status: '',
is_primary: false
})
setIsFormOpen(true)
}
const handleEdit = (contact: Contact) => {
setEditingContact({ ...contact })
setIsFormOpen(true)
}
const handleSave = () => {
if (editingContact) {
if (editingContact.id) {
onEditContact && onEditContact(editingContact)
} else {
onAddContact && onAddContact(editingContact)
}
}
setIsFormOpen(false)
setEditingContact(null)
}
if (isFormOpen && editingContact) {
return (
<div className="bg-slate-900/50 rounded-lg p-4 border border-slate-700 space-y-4 animate-in fade-in slide-in-from-bottom-2">
<div className="flex justify-between items-center border-b border-slate-700 pb-2 mb-2">
<h3 className="text-sm font-bold text-white">
{editingContact.id ? 'Edit Contact' : 'New Contact'}
</h3>
<button onClick={() => setIsFormOpen(false)} className="text-slate-400 hover:text-white">
<X className="h-4 w-4" />
</button>
</div>
{/* Salutation / Address Section */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Gender / Salutation</label>
<select
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.gender}
onChange={e => setEditingContact({...editingContact, gender: e.target.value as any})}
>
<option value="männlich">Male / Herr</option>
<option value="weiblich">Female / Frau</option>
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Academic Title</label>
<input
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.title}
placeholder="e.g. Dr., Prof."
onChange={e => setEditingContact({...editingContact, title: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">First Name</label>
<input
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.first_name}
onChange={e => setEditingContact({...editingContact, first_name: e.target.value})}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Last Name</label>
<input
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.last_name}
onChange={e => setEditingContact({...editingContact, last_name: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Email</label>
<input
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.email}
onChange={e => setEditingContact({...editingContact, email: e.target.value})}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Job Title (Card)</label>
<input
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.job_title}
onChange={e => setEditingContact({...editingContact, job_title: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Our Role Interpretation</label>
<select
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.role}
onChange={e => setEditingContact({...editingContact, role: e.target.value as ContactRole})}
>
{Object.keys(roleColors).map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Marketing Status</label>
<select
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.status}
onChange={e => setEditingContact({...editingContact, status: e.target.value as ContactStatus})}
>
<option value="">&lt;leer&gt;</option>
{Object.keys(statusColors).filter(s => s !== '').map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] uppercase text-slate-500 font-bold">Language</label>
<select
className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:border-blue-500 outline-none"
value={editingContact.language}
onChange={e => setEditingContact({...editingContact, language: e.target.value as any})}
>
<option value="De">De</option>
<option value="En">En</option>
</select>
</div>
<div className="flex items-center pt-5">
<label className="flex items-center gap-2 cursor-pointer text-sm text-slate-300 hover:text-white">
<input
type="checkbox"
checked={editingContact.is_primary}
onChange={e => setEditingContact({...editingContact, is_primary: e.target.checked})}
className="rounded border-slate-700 bg-slate-800 text-blue-500 focus:ring-blue-500"
/>
Primary Contact
</label>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
onClick={handleSave}
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold py-2 rounded flex items-center justify-center gap-2"
>
<Save className="h-4 w-4" /> Save Contact
</button>
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
<Users className="h-4 w-4" /> Contacts List
</h3>
<button
onClick={handleAddNew}
className="flex items-center gap-1 px-3 py-1 bg-blue-600/20 text-blue-400 border border-blue-500/30 rounded hover:bg-blue-600 hover:text-white transition-all text-xs font-bold"
>
<Plus className="h-3.5 w-3.5" /> ADD
</button>
</div>
<div className="space-y-3">
{contacts.length === 0 ? (
<div className="p-8 rounded-xl border border-dashed border-slate-800 text-center text-slate-600">
<Users className="h-8 w-8 mx-auto mb-3 opacity-20" />
<p className="text-sm font-medium">No contacts yet.</p>
<p className="text-xs mt-1 opacity-70">Click "ADD" to create the first contact for this account.</p>
</div>
) : (
contacts.map(contact => (
<div
key={contact.id}
className={clsx(
"relative bg-slate-800/30 border rounded-lg p-3 transition-all hover:bg-slate-800/50 group cursor-pointer",
contact.is_primary ? "border-blue-500/30 shadow-lg shadow-blue-900/10" : "border-slate-800"
)}
onClick={() => handleEdit(contact)}
>
{contact.is_primary && (
<div className="absolute top-2 right-2 text-blue-500" title="Primary Contact">
<Star className="h-3 w-3 fill-current" />
</div>
)}
<div className="flex items-start gap-3">
<div className="p-2 bg-slate-900 rounded-full text-slate-400 shrink-0 mt-1">
<User className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-bold text-slate-200 truncate">
{contact.title ? `${contact.title} ` : ''}{contact.first_name} {contact.last_name}
</span>
<span className="text-[10px] text-slate-500 border border-slate-700 px-1 rounded">
{contact.language}
</span>
</div>
<div className="text-xs text-slate-400 mb-2 truncate font-medium">
{contact.job_title}
</div>
<div className="flex flex-wrap gap-2 mb-2">
<span className={clsx("text-[10px] px-1.5 py-0.5 rounded border font-medium", roleColors[contact.role] || "text-slate-400 border-slate-700")}>
{contact.role}
</span>
</div>
<div className="flex items-center gap-3 text-[10px] text-slate-500 font-mono">
<div className="flex items-center gap-1 truncate">
<Mail className="h-3 w-3" />
{contact.email}
</div>
<div className={clsx("flex items-center gap-1 font-bold ml-auto mr-8", statusColors[contact.status])}>
<Activity className="h-3 w-3" />
{contact.status || '<leer>'}
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
)
}

View 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>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import axios from 'axios'
import { X, Save, Settings, Loader2 } from 'lucide-react'
import { X, Bot, Tag, Target, Users, Plus, Trash2, Save } from 'lucide-react'
import clsx from 'clsx'
interface RoboticsSettingsProps {
isOpen: boolean
@@ -8,127 +9,268 @@ interface RoboticsSettingsProps {
apiBase: string
}
type Category = {
id: number
key: string
name: string
description: string
reasoning_guide: string
}
export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsProps) {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(false)
const [savingId, setSavingId] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles'>('robotics')
// Data States
const [roboticsCategories, setRoboticsCategories] = useState<any[]>([])
const [industries, setIndustries] = useState<any[]>([])
const [jobRoles, setJobRoles] = useState<any[]>([])
const fetchRobotics = async () => {
try { const res = await axios.get(`${apiBase}/robotics/categories`); setRoboticsCategories(res.data) } catch (e) { console.error(e) }
}
const fetchIndustries = async () => {
try { const res = await axios.get(`${apiBase}/industries`); setIndustries(res.data) } catch (e) { console.error(e) }
}
const fetchJobRoles = async () => {
try { const res = await axios.get(`${apiBase}/job_roles`); setJobRoles(res.data) } catch (e) { console.error(e) }
}
useEffect(() => {
if (isOpen) {
setLoading(true)
axios.get(`${apiBase}/robotics/categories`)
.then(res => setCategories(res.data))
.catch(console.error)
.finally(() => setLoading(false))
fetchRobotics()
fetchIndustries()
fetchJobRoles()
}
}, [isOpen])
const handleSave = async (cat: Category) => {
setSavingId(cat.id)
try {
await axios.put(`${apiBase}/robotics/categories/${cat.id}`, {
description: cat.description,
reasoning_guide: cat.reasoning_guide
})
// Success indicator?
} catch (e) {
alert("Failed to save settings")
} finally {
setSavingId(null)
}
// Robotics Handlers
const handleUpdateRobotics = async (id: number, description: string, reasoning: string) => {
try {
await axios.put(`${apiBase}/robotics/categories/${id}`, { description, reasoning_guide: reasoning })
fetchRobotics()
} catch (e) { alert("Update failed") }
}
const handleChange = (id: number, field: keyof Category, value: string) => {
setCategories(prev => prev.map(c =>
c.id === id ? { ...c, [field]: value } : c
))
// Industry Handlers
const handleAddIndustry = async () => {
try { await axios.post(`${apiBase}/industries`, { name: "New Industry" }); fetchIndustries() } catch (e) { alert("Failed") }
}
const handleUpdateIndustry = async (id: number, data: any) => {
try { await axios.put(`${apiBase}/industries/${id}`, data); fetchIndustries() } catch (e) { alert("Failed") }
}
const handleDeleteIndustry = async (id: number) => {
try { await axios.delete(`${apiBase}/industries/${id}`); fetchIndustries() } catch (e) { alert("Failed") }
}
// Job Role Handlers
const handleAddJobRole = async () => {
try { await axios.post(`${apiBase}/job_roles`, { pattern: "New Pattern", role: "Operativer Entscheider" }); fetchJobRoles() } catch (e) { alert("Failed") }
}
const handleDeleteJobRole = async (id: number) => {
try { await axios.delete(`${apiBase}/job_roles/${id}`); fetchJobRoles() } catch (e) { alert("Failed") }
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="bg-slate-900 border border-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white dark:bg-slate-900 w-full max-w-4xl max-h-[85vh] rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-800 flex flex-col overflow-hidden">
{/* Header */}
<div className="p-6 border-b border-slate-800 flex justify-between items-center bg-slate-950/50 rounded-t-xl">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-600/20 rounded-lg text-blue-400">
<Settings className="h-6 w-6" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Robotics Logic Configuration</h2>
<p className="text-sm text-slate-400">Define how the AI assesses potential for each category.</p>
</div>
<div className="p-6 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center bg-slate-50 dark:bg-slate-950/50">
<div>
<h2 className="text-xl font-bold text-slate-900 dark:text-white">Settings & Classification Logic</h2>
<p className="text-sm text-slate-500">Define how AI evaluates leads and matches roles.</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<button onClick={onClose} className="p-2 hover:bg-slate-200 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500">
<X className="h-6 w-6" />
</button>
</div>
{/* Tab Nav */}
<div className="flex border-b border-slate-200 dark:border-slate-800 px-6 bg-white dark:bg-slate-900 overflow-x-auto">
{[
{ id: 'robotics', label: 'Robotics Potential', icon: Bot },
{ id: 'industries', label: 'Industry Focus', icon: Target },
{ id: 'roles', label: 'Job Role Mapping', icon: Users },
].map(t => (
<button
key={t.id}
onClick={() => setActiveTab(t.id as any)}
className={clsx(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-all whitespace-nowrap",
activeTab === t.id
? "border-blue-500 text-blue-600 dark:text-blue-400"
: "border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-slate-300"
)}
>
<t.icon className="h-4 w-4" /> {t.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{loading ? (
<div className="flex items-center justify-center py-20 text-slate-500">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<div className="grid grid-cols-1 gap-6">
{categories.map(cat => (
<div key={cat.id} className="bg-slate-800/30 border border-slate-700/50 rounded-lg p-5">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-bold text-white flex items-center gap-2">
<span className="capitalize">{cat.name}</span>
<span className="text-xs font-mono text-slate-500 bg-slate-900 px-1.5 py-0.5 rounded border border-slate-800">{cat.key}</span>
</h3>
<button
onClick={() => handleSave(cat)}
disabled={savingId === cat.id}
className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-xs font-bold rounded transition-colors"
>
{savingId === cat.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
SAVE
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-white dark:bg-slate-900">
{/* ROBOTICS TAB */}
{activeTab === 'robotics' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{roboticsCategories.map(cat => (
<CategoryCard key={cat.id} category={cat} onSave={handleUpdateRobotics} />
))}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Definition (When to trigger?)</label>
<textarea
value={cat.description}
onChange={(e) => handleChange(cat.id, 'description', e.target.value)}
className="w-full h-32 bg-slate-950 border border-slate-700 rounded p-3 text-sm text-slate-200 focus:ring-1 focus:ring-blue-500 outline-none resize-none font-mono leading-relaxed"
/>
<p className="text-[10px] text-slate-500">
Instructions for the AI on what business models or assets imply this need.
</p>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Scoring Guide (High/Med/Low)</label>
<textarea
value={cat.reasoning_guide}
onChange={(e) => handleChange(cat.id, 'reasoning_guide', e.target.value)}
className="w-full h-32 bg-slate-950 border border-slate-700 rounded p-3 text-sm text-slate-200 focus:ring-1 focus:ring-blue-500 outline-none resize-none font-mono leading-relaxed"
/>
<p className="text-[10px] text-slate-500">
Explicit examples for scoring logic to ensure consistency.
</p>
</div>
{/* INDUSTRIES TAB */}
{activeTab === 'industries' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-300">Industry Verticals</h3>
<button onClick={handleAddIndustry} className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded">
<Plus className="h-3 w-3" /> ADD NEW
</button>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-3">
{industries.map(ind => (
<div key={ind.id} className="bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg p-4 flex gap-4 items-start group">
<div className="flex-1 space-y-2">
<div className="flex gap-2">
<input
className="bg-transparent border-b border-transparent focus:border-blue-500 outline-none font-bold text-slate-900 dark:text-white text-sm w-full"
defaultValue={ind.name}
onBlur={(e) => handleUpdateIndustry(ind.id, { name: e.target.value })}
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={ind.is_focus}
onChange={(e) => handleUpdateIndustry(ind.id, { is_focus: e.target.checked })}
className="rounded border-slate-300 dark:border-slate-700"
/>
<span className="text-xs text-slate-500">Focus?</span>
</div>
</div>
<textarea
className="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded p-2 text-xs text-slate-600 dark:text-slate-300 focus:ring-1 focus:ring-blue-500 outline-none h-16 resize-none"
defaultValue={ind.description}
placeholder="Description / Abgrenzung..."
onBlur={(e) => handleUpdateIndustry(ind.id, { description: e.target.value })}
/>
<select
className="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded p-1.5 text-xs text-slate-600 dark:text-slate-300 outline-none"
value={ind.primary_category_id || ""}
onChange={(e) => handleUpdateIndustry(ind.id, { primary_category_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">-- No Primary Product --</option>
{roboticsCategories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<button onClick={() => handleDeleteIndustry(ind.id)} className="p-2 text-slate-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* JOB ROLES TAB */}
{activeTab === 'roles' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-300">Job Title Mapping Patterns</h3>
<button onClick={handleAddJobRole} className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded">
<Plus className="h-3 w-3" /> ADD PATTERN
</button>
</div>
<div className="bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden">
<table className="w-full text-left text-xs">
<thead className="bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 text-slate-500 font-bold uppercase">
<tr>
<th className="p-3">Job Title Pattern (Regex/Text)</th>
<th className="p-3">Mapped Role</th>
<th className="p-3 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200 dark:divide-slate-800">
{jobRoles.map(role => (
<tr key={role.id} className="group">
<td className="p-2">
<input
className="w-full bg-transparent border border-transparent hover:border-slate-300 dark:hover:border-slate-700 rounded px-2 py-1 text-slate-900 dark:text-slate-200 outline-none focus:border-blue-500"
defaultValue={role.pattern}
// Real-time update would require more state management or blur
/>
</td>
<td className="p-2">
<select
className="w-full bg-transparent border border-transparent hover:border-slate-300 dark:hover:border-slate-700 rounded px-2 py-1 text-slate-900 dark:text-slate-200 outline-none focus:border-blue-500"
defaultValue={role.role}
>
<option>Operativer Entscheider</option>
<option>Infrastruktur-Verantwortlicher</option>
<option>Wirtschaftlicher Entscheider</option>
<option>Innovations-Treiber</option>
</select>
</td>
<td className="p-2 text-center">
<button onClick={() => handleDeleteJobRole(role.id)} className="text-slate-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
{jobRoles.length === 0 && (
<tr><td colSpan={3} className="p-8 text-center text-slate-500 italic">No patterns defined yet.</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
</div>
)
}
function CategoryCard({ category, onSave }: { category: any, onSave: any }) {
const [desc, setDesc] = useState(category.description)
const [guide, setGuide] = useState(category.reasoning_guide)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
setIsDirty(desc !== category.description || guide !== category.reasoning_guide)
}, [desc, guide])
return (
<div className="bg-slate-50 dark:bg-slate-950/50 border border-slate-200 dark:border-slate-800 rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded">
<Tag className="h-4 w-4" />
</div>
<span className="font-bold text-slate-900 dark:text-white uppercase tracking-tight text-sm">{category.name}</span>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Definition for LLM</label>
<textarea
className="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded p-2 text-xs text-slate-800 dark:text-slate-200 focus:ring-1 focus:ring-blue-500 outline-none h-20"
value={desc}
onChange={e => setDesc(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Reasoning Guide (Scoring)</label>
<textarea
className="w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded p-2 text-xs text-slate-800 dark:text-slate-200 focus:ring-1 focus:ring-blue-500 outline-none h-20"
value={guide}
onChange={e => setGuide(e.target.value)}
/>
</div>
{isDirty && (
<button
onClick={() => onSave(category.id, desc, guide)}
className="mt-2 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold py-1.5 rounded transition-all animate-in fade-in flex items-center justify-center gap-1"
>
<Save className="h-3 w-3" /> SAVE CHANGES
</button>
)}
</div>
)
}