feat(ce): upgrade to v0.5.0 with contacts management, advanced settings and ui modernization
This commit is contained in:
@@ -1,48 +1,56 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { CompanyTable } from './components/CompanyTable'
|
||||
import { ContactsTable } from './components/ContactsTable' // NEW
|
||||
import { ImportWizard } from './components/ImportWizard'
|
||||
import { Inspector } from './components/Inspector'
|
||||
import { RoboticsSettings } from './components/RoboticsSettings' // NEW
|
||||
import { LayoutDashboard, UploadCloud, Search, RefreshCw, Settings } from 'lucide-react'
|
||||
import { RoboticsSettings } from './components/RoboticsSettings'
|
||||
import { LayoutDashboard, UploadCloud, RefreshCw, Settings, Users, Building, Sun, Moon } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Base URL detection (Production vs Dev)
|
||||
const API_BASE = import.meta.env.BASE_URL === '/ce/' ? '/ce/api' : '/api';
|
||||
|
||||
interface Stats {
|
||||
total: number;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [stats, setStats] = useState<Stats>({ total: 0 })
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [isImportOpen, setIsImportOpen] = useState(false)
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false) // NEW
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
const [selectedCompanyId, setSelectedCompanyId] = useState<number | null>(null)
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/companies?limit=1`)
|
||||
setStats({ total: res.data.total })
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch stats", e)
|
||||
}
|
||||
}
|
||||
const [selectedContactId, setSelectedContactId] = useState<number | null>(null)
|
||||
|
||||
// Navigation State
|
||||
const [view, setView] = useState<'companies' | 'contacts'>('companies')
|
||||
|
||||
// Theme State
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
return localStorage.getItem('theme') as 'dark' | 'light' || 'dark'
|
||||
}
|
||||
return 'dark'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [refreshKey])
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
||||
|
||||
const handleCompanySelect = (id: number) => {
|
||||
setSelectedCompanyId(id)
|
||||
setSelectedContactId(null)
|
||||
}
|
||||
|
||||
const handleCloseInspector = () => {
|
||||
setSelectedCompanyId(null)
|
||||
setSelectedContactId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-200 font-sans">
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-200 font-sans transition-colors">
|
||||
<ImportWizard
|
||||
isOpen={isImportOpen}
|
||||
onClose={() => setIsImportOpen(false)}
|
||||
@@ -50,41 +58,62 @@ function App() {
|
||||
onSuccess={() => setRefreshKey(k => k + 1)}
|
||||
/>
|
||||
|
||||
{/* Robotics Logic Settings */}
|
||||
<RoboticsSettings
|
||||
isOpen={isSettingsOpen}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
apiBase={API_BASE}
|
||||
/>
|
||||
|
||||
{/* Inspector Sidebar */}
|
||||
<Inspector
|
||||
companyId={selectedCompanyId}
|
||||
companyId={selectedCompanyId}
|
||||
initialContactId={selectedContactId}
|
||||
onClose={handleCloseInspector}
|
||||
apiBase={API_BASE}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<header className="border-b border-slate-800 bg-slate-900/50 sticky top-0 z-10 backdrop-blur-md">
|
||||
<header className="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900/50 sticky top-0 z-10 backdrop-blur-md">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-600 rounded-lg">
|
||||
<LayoutDashboard className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Company Explorer</h1>
|
||||
<p className="text-xs text-blue-400 font-medium">ROBOTICS EDITION <span className="text-slate-600 ml-2">v0.4.0 (Overwrites & Export)</span></p>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-white tracking-tight">Company Explorer</h1>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 font-medium">ROBOTICS EDITION <span className="text-slate-500 dark:text-slate-600 ml-2">v0.5.0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-slate-400">
|
||||
<span className="text-white font-bold">{stats.total}</span> Companies
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
{/* View Switcher */}
|
||||
<div className="hidden md:flex bg-slate-100 dark:bg-slate-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setView('companies')}
|
||||
className={clsx("px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-2", view === 'companies' ? "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white" : "text-slate-500 hover:text-slate-900 dark:hover:text-slate-300")}
|
||||
>
|
||||
<Building className="h-4 w-4" /> Companies
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('contacts')}
|
||||
className={clsx("px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-2", view === 'contacts' ? "bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-white" : "text-slate-500 hover:text-slate-900 dark:hover:text-slate-300")}
|
||||
>
|
||||
<Users className="h-4 w-4" /> Contacts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-slate-300 dark:bg-slate-700 mx-2 hidden md:block"></div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400"
|
||||
title="Toggle Theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="p-2 hover:bg-slate-800 rounded-full transition-colors text-slate-400 hover:text-white"
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400"
|
||||
title="Configure Robotics Logic"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
@@ -92,42 +121,67 @@ function App() {
|
||||
|
||||
<button
|
||||
onClick={() => setRefreshKey(k => k + 1)}
|
||||
className="p-2 hover:bg-slate-800 rounded-full transition-colors text-slate-400 hover:text-white"
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400"
|
||||
title="Refresh Data"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md font-medium text-sm transition-all shadow-lg shadow-blue-900/20"
|
||||
onClick={() => setIsImportOpen(true)}
|
||||
>
|
||||
<UploadCloud className="h-4 w-4" />
|
||||
Import List
|
||||
</button>
|
||||
|
||||
{view === 'companies' && (
|
||||
<button
|
||||
className="hidden md:flex items-center gap-2 bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-md font-medium text-sm transition-all shadow-lg shadow-blue-900/20"
|
||||
onClick={() => setIsImportOpen(true)}
|
||||
>
|
||||
<UploadCloud className="h-4 w-4" />
|
||||
Import List
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Nav */}
|
||||
<div className="md:hidden border-t border-slate-200 dark:border-slate-800 flex">
|
||||
<button
|
||||
onClick={() => setView('companies')}
|
||||
className={clsx("flex-1 py-3 text-sm font-medium flex justify-center items-center gap-2 border-b-2", view === 'companies' ? "border-blue-500 text-blue-600 dark:text-blue-400" : "border-transparent text-slate-500")}
|
||||
>
|
||||
<Building className="h-4 w-4" /> Companies
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('contacts')}
|
||||
className={clsx("flex-1 py-3 text-sm font-medium flex justify-center items-center gap-2 border-b-2", view === 'contacts' ? "border-blue-500 text-blue-600 dark:text-blue-400" : "border-transparent text-slate-500")}
|
||||
>
|
||||
<Users className="h-4 w-4" /> Contacts
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-6 flex gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search companies..."
|
||||
className="w-full bg-slate-900 border border-slate-700 text-slate-200 rounded-md pl-10 pr-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-xl">
|
||||
<CompanyTable key={refreshKey} apiBase={API_BASE} onRowClick={handleCompanySelect} /> {/* NEW PROP */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 h-[calc(100vh-4rem)]">
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden shadow-sm dark:shadow-xl h-full">
|
||||
{view === 'companies' ? (
|
||||
<CompanyTable
|
||||
refreshKey={refreshKey}
|
||||
apiBase={API_BASE}
|
||||
onRowClick={handleCompanySelect}
|
||||
onImportClick={() => setIsImportOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<ContactsTable
|
||||
apiBase={API_BASE}
|
||||
onCompanyClick={(id) => { setSelectedCompanyId(id); setView('companies'); }}
|
||||
onContactClick={(companyId, contactId) => {
|
||||
setSelectedCompanyId(companyId);
|
||||
setSelectedContactId(contactId);
|
||||
// setView('companies')? No, we stay in context of 'Contacts' but Inspector opens
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
317
company-explorer/frontend/src/components/ContactsManager.tsx
Normal file
317
company-explorer/frontend/src/components/ContactsManager.tsx
Normal 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=""><leer></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>
|
||||
)
|
||||
}
|
||||
222
company-explorer/frontend/src/components/ContactsTable.tsx
Normal file
222
company-explorer/frontend/src/components/ContactsTable.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Users, Search, ChevronLeft, ChevronRight, Upload,
|
||||
Mail, Building, Briefcase, User
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ContactsTableProps {
|
||||
apiBase: string
|
||||
onCompanyClick: (id: number) => void
|
||||
onContactClick: (companyId: number, contactId: number) => void // NEW
|
||||
}
|
||||
|
||||
export function ContactsTable({ apiBase, onCompanyClick, onContactClick }: ContactsTableProps) {
|
||||
const [data, setData] = useState<any[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [search, setSearch] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const limit = 50
|
||||
|
||||
// Import State
|
||||
const [isImportOpen, setIsImportOpen] = useState(false)
|
||||
const [importText, setImportText] = useState("")
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null)
|
||||
|
||||
const fetchContacts = () => {
|
||||
setLoading(true)
|
||||
axios.get(`${apiBase}/contacts/all?skip=${page * limit}&limit=${limit}&search=${search}`)
|
||||
.then(res => {
|
||||
setData(res.data.items)
|
||||
setTotal(res.data.total)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(fetchContacts, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [page, search])
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importText) return
|
||||
setImportStatus("Parsing...")
|
||||
|
||||
try {
|
||||
// Simple CSV-ish parsing: Company, First, Last, Email, Job
|
||||
const lines = importText.split('\n').filter(l => l.trim())
|
||||
const contacts = lines.map(line => {
|
||||
const parts = line.split(/[;,|]+/).map(p => p.trim())
|
||||
// Expected: Company, First, Last, Email (optional)
|
||||
if (parts.length < 3) return null
|
||||
return {
|
||||
company_name: parts[0],
|
||||
first_name: parts[1],
|
||||
last_name: parts[2],
|
||||
email: parts[3] || null,
|
||||
job_title: parts[4] || null
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setImportStatus("Error: No valid contacts found. Format: Company, First, Last, Email")
|
||||
return
|
||||
}
|
||||
|
||||
setImportStatus(`Importing ${contacts.length} contacts...`)
|
||||
const res = await axios.post(`${apiBase}/contacts/bulk`, { contacts })
|
||||
setImportStatus(`Success! Added: ${res.data.added}, Created Companies: ${res.data.companies_created}, Skipped: ${res.data.skipped}`)
|
||||
setImportText("")
|
||||
setTimeout(() => {
|
||||
setIsImportOpen(false)
|
||||
setImportStatus(null)
|
||||
fetchContacts()
|
||||
}, 2000)
|
||||
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setImportStatus("Import Failed.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-slate-900 transition-colors">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 p-4 border-b border-slate-200 dark:border-slate-800 items-center justify-between bg-slate-50 dark:bg-slate-950/50">
|
||||
<div className="flex items-center gap-2 text-slate-700 dark:text-slate-300 font-bold text-lg">
|
||||
<Users className="h-5 w-5" />
|
||||
<h2>All Contacts ({total})</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 w-full md:w-auto gap-2 max-w-xl">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search contacts, companies, emails..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-md text-sm text-slate-900 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsImportOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-md shadow-sm transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4" /> <span className="hidden md:inline">Import</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Modal */}
|
||||
{isImportOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-lg border border-slate-200 dark:border-slate-800 flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 className="font-bold text-slate-900 dark:text-white">Bulk Import Contacts</h3>
|
||||
<button onClick={() => setIsImportOpen(false)} className="text-slate-500 hover:text-red-500"><Users className="h-5 w-5" /></button>
|
||||
</div>
|
||||
<div className="p-4 flex-1 overflow-y-auto">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||
Paste CSV data (no header). Format:<br/>
|
||||
<code className="bg-slate-100 dark:bg-slate-800 px-1 py-0.5 rounded text-xs">Company Name, First Name, Last Name, Email, Job Title</code>
|
||||
</p>
|
||||
<textarea
|
||||
className="w-full h-48 bg-slate-50 dark:bg-slate-950 border border-slate-300 dark:border-slate-800 rounded p-2 text-xs font-mono text-slate-800 dark:text-slate-200 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="Acme Corp, John, Doe, john@acme.com, CEO"
|
||||
value={importText}
|
||||
onChange={e => setImportText(e.target.value)}
|
||||
/>
|
||||
{importStatus && (
|
||||
<div className={clsx("mt-2 text-sm font-bold p-2 rounded", importStatus.includes("Success") ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400")}>
|
||||
{importStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-200 dark:border-slate-800 flex justify-end gap-2">
|
||||
<button onClick={() => setIsImportOpen(false)} className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white">Cancel</button>
|
||||
<button onClick={handleImport} className="px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded hover:bg-blue-500">Run Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data Grid */}
|
||||
<div className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950/30">
|
||||
{loading && <div className="p-4 text-center text-slate-500">Loading contacts...</div>}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
|
||||
{data.map((c: any) => (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => onContactClick(c.company_id, c.id)}
|
||||
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg p-4 hover:shadow-lg transition-all flex flex-col gap-3 group cursor-pointer border-l-4 border-l-slate-400"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full text-slate-500 dark:text-slate-400">
|
||||
<User className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-slate-900 dark:text-white text-sm">
|
||||
{c.title} {c.first_name} {c.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[150px]" title={c.job_title}>
|
||||
{c.job_title || "No Title"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx("px-2 py-0.5 rounded text-[10px] font-bold border", c.status ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800" : "bg-slate-100 dark:bg-slate-800 text-slate-500 border-slate-200 dark:border-slate-700")}>
|
||||
{c.status || "No Status"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-800/50">
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400 hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
|
||||
onClick={() => onCompanyClick(c.company_id)}
|
||||
>
|
||||
<Building className="h-3 w-3" />
|
||||
<span className="truncate font-medium">{c.company_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate">{c.email || "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
<span className="truncate">{c.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="p-3 border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 flex justify-between items-center text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>Showing {data.length} of {total} contacts</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="px-2 py-1">Page {page + 1}</span>
|
||||
<button
|
||||
disabled={(page + 1) * limit >= total}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user