- Fixed a critical in the company-explorer by forcing a database re-initialization with a new file (). This ensures the application code is in sync with the database schema. - Documented the schema mismatch incident and its resolution in MIGRATION_PLAN.md. - Restored and enhanced BUILDER_APPS_MIGRATION.md by recovering extensive, valuable content from the git history that was accidentally deleted. The guide now again includes detailed troubleshooting steps and code templates for common migration pitfalls.
787 lines
41 KiB
TypeScript
787 lines
41 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import axios from 'axios'
|
|
import { X, ExternalLink, Bot, Briefcase, Calendar, Globe, Users, DollarSign, MapPin, Tag, RefreshCw as RefreshCwIcon, Search as SearchIcon, Pencil, Check, Download, Clock, Lock, Unlock } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import { ContactsManager, Contact } from './ContactsManager'
|
|
|
|
interface InspectorProps {
|
|
companyId: number | null
|
|
initialContactId?: number | null // NEW
|
|
onClose: () => void
|
|
apiBase: string
|
|
}
|
|
|
|
type Signal = {
|
|
signal_type: string
|
|
confidence: number
|
|
value: string
|
|
proof_text: string
|
|
}
|
|
|
|
type EnrichmentData = {
|
|
source_type: string
|
|
content: any
|
|
is_locked?: boolean
|
|
created_at?: string
|
|
}
|
|
|
|
type CompanyDetail = {
|
|
id: number
|
|
name: string
|
|
website: string | null
|
|
industry_ai: string | null
|
|
status: string
|
|
created_at: string
|
|
signals: Signal[]
|
|
enrichment_data: EnrichmentData[]
|
|
contacts?: Contact[]
|
|
}
|
|
|
|
export function Inspector({ companyId, initialContactId, onClose, apiBase }: InspectorProps) {
|
|
const [data, setData] = useState<CompanyDetail | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [isProcessing, setIsProcessing] = useState(false)
|
|
const [activeTab, setActiveTab] = useState<'overview' | 'contacts'>('overview')
|
|
|
|
// Polling Logic
|
|
useEffect(() => {
|
|
let interval: NodeJS.Timeout;
|
|
if (isProcessing) {
|
|
interval = setInterval(() => {
|
|
fetchData(true) // Silent fetch
|
|
}, 2000)
|
|
}
|
|
return () => clearInterval(interval)
|
|
}, [isProcessing, companyId]) // Dependencies
|
|
|
|
// Auto-switch to contacts tab if initialContactId is present
|
|
useEffect(() => {
|
|
if (initialContactId) {
|
|
setActiveTab('contacts')
|
|
} else {
|
|
setActiveTab('overview')
|
|
}
|
|
}, [initialContactId, companyId])
|
|
|
|
// Manual Override State
|
|
const [isEditingWiki, setIsEditingWiki] = useState(false)
|
|
const [wikiUrlInput, setWikiUrlInput] = useState("")
|
|
const [isEditingWebsite, setIsEditingWebsite] = useState(false)
|
|
const [websiteInput, setWebsiteInput] = useState("")
|
|
const [isEditingImpressum, setIsEditingImpressum] = useState(false)
|
|
const [impressumUrlInput, setImpressumUrlInput] = useState("")
|
|
|
|
const fetchData = (silent = false) => {
|
|
if (!companyId) return
|
|
if (!silent) setLoading(true)
|
|
|
|
axios.get(`${apiBase}/companies/${companyId}`)
|
|
.then(res => {
|
|
const newData = res.data
|
|
setData(newData)
|
|
|
|
// Auto-stop processing if status changes to ENRICHED or we see data
|
|
if (isProcessing) {
|
|
const hasWiki = newData.enrichment_data?.some((e:any) => e.source_type === 'wikipedia')
|
|
const hasAnalysis = newData.enrichment_data?.some((e:any) => e.source_type === 'ai_analysis')
|
|
|
|
// If we were waiting for Discover (Wiki) or Analyze (AI)
|
|
if ((hasWiki && newData.status === 'DISCOVERED') || (hasAnalysis && newData.status === 'ENRICHED')) {
|
|
setIsProcessing(false)
|
|
}
|
|
}
|
|
})
|
|
.catch(console.error)
|
|
.finally(() => { if (!silent) setLoading(false) })
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
setIsEditingWiki(false)
|
|
setIsEditingWebsite(false)
|
|
setIsEditingImpressum(false)
|
|
setIsProcessing(false) // Reset on ID change
|
|
}, [companyId])
|
|
|
|
const handleDiscover = async () => {
|
|
if (!companyId) return
|
|
setIsProcessing(true)
|
|
try {
|
|
await axios.post(`${apiBase}/enrich/discover`, { company_id: companyId })
|
|
// Polling effect will handle the rest
|
|
} catch (e) {
|
|
console.error(e)
|
|
setIsProcessing(false)
|
|
}
|
|
}
|
|
|
|
const handleAnalyze = async () => {
|
|
if (!companyId) return
|
|
setIsProcessing(true)
|
|
try {
|
|
await axios.post(`${apiBase}/enrich/analyze`, { company_id: companyId })
|
|
// Polling effect will handle the rest
|
|
} catch (e) {
|
|
console.error(e)
|
|
setIsProcessing(false)
|
|
}
|
|
}
|
|
|
|
const handleExport = () => {
|
|
if (!data) return;
|
|
|
|
// Prepare full export object
|
|
const exportData = {
|
|
metadata: {
|
|
id: data.id,
|
|
exported_at: new Date().toISOString(),
|
|
source: "Company Explorer (Robotics Edition)"
|
|
},
|
|
company: {
|
|
name: data.name,
|
|
website: data.website,
|
|
status: data.status,
|
|
industry_ai: data.industry_ai,
|
|
created_at: data.created_at
|
|
},
|
|
enrichment: data.enrichment_data,
|
|
signals: data.signals
|
|
};
|
|
|
|
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `company-export-${data.id}-${data.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleWikiOverride = async () => {
|
|
if (!companyId) return
|
|
setIsProcessing(true)
|
|
try {
|
|
await axios.post(`${apiBase}/companies/${companyId}/override/wiki?url=${encodeURIComponent(wikiUrlInput)}`)
|
|
setIsEditingWiki(false)
|
|
fetchData()
|
|
} catch (e) {
|
|
alert("Update failed")
|
|
console.error(e)
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}
|
|
|
|
const handleWebsiteOverride = async () => {
|
|
if (!companyId) return
|
|
setIsProcessing(true)
|
|
try {
|
|
await axios.post(`${apiBase}/companies/${companyId}/override/website?url=${encodeURIComponent(websiteInput)}`)
|
|
setIsEditingWebsite(false)
|
|
fetchData()
|
|
} catch (e) {
|
|
alert("Update failed")
|
|
console.error(e)
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}
|
|
|
|
const handleImpressumOverride = async () => {
|
|
if (!companyId) return
|
|
setIsProcessing(true)
|
|
try {
|
|
await axios.post(`${apiBase}/companies/${companyId}/override/impressum?url=${encodeURIComponent(impressumUrlInput)}`)
|
|
setIsEditingImpressum(false)
|
|
fetchData()
|
|
} catch (e) {
|
|
alert("Impressum update failed")
|
|
console.error(e)
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}
|
|
|
|
const handleLockToggle = async (sourceType: string, currentLockStatus: boolean) => {
|
|
if (!companyId) return
|
|
try {
|
|
await axios.post(`${apiBase}/enrichment/${companyId}/${sourceType}/lock?locked=${!currentLockStatus}`)
|
|
fetchData(true) // Silent refresh
|
|
} catch (e) {
|
|
console.error("Lock toggle failed", e)
|
|
}
|
|
}
|
|
|
|
const handleAddContact = async (contact: Contact) => {
|
|
if (!companyId) return
|
|
try {
|
|
await axios.post(`${apiBase}/contacts`, { ...contact, company_id: companyId })
|
|
fetchData(true)
|
|
} catch (e) {
|
|
alert("Failed to add contact")
|
|
console.error(e)
|
|
}
|
|
}
|
|
|
|
const handleEditContact = async (contact: Contact) => {
|
|
if (!contact.id) return
|
|
try {
|
|
await axios.put(`${apiBase}/contacts/${contact.id}`, contact)
|
|
fetchData(true)
|
|
} catch (e) {
|
|
alert("Failed to update contact")
|
|
console.error(e)
|
|
}
|
|
}
|
|
|
|
if (!companyId) return null
|
|
|
|
const wikiEntry = data?.enrichment_data?.find(e => e.source_type === 'wikipedia')
|
|
const wiki = wikiEntry?.content
|
|
const isLocked = wikiEntry?.is_locked
|
|
const wikiDate = wikiEntry?.created_at
|
|
|
|
const aiAnalysisEntry = data?.enrichment_data?.find(e => e.source_type === 'ai_analysis')
|
|
const aiAnalysis = aiAnalysisEntry?.content
|
|
const aiDate = aiAnalysisEntry?.created_at
|
|
|
|
const scrapeEntry = data?.enrichment_data?.find(e => e.source_type === 'website_scrape')
|
|
const scrapeData = scrapeEntry?.content
|
|
const impressum = scrapeData?.impressum
|
|
const scrapeDate = scrapeEntry?.created_at
|
|
|
|
return (
|
|
<div className="fixed inset-y-0 right-0 w-full md:w-[550px] bg-white dark:bg-slate-900 border-l border-slate-200 dark:border-slate-800 shadow-2xl transform transition-transform duration-300 ease-in-out z-50 overflow-y-auto">
|
|
{loading ? (
|
|
<div className="p-8 text-slate-500">Loading details...</div>
|
|
) : !data ? (
|
|
<div className="p-8 text-red-400">Failed to load data.</div>
|
|
) : (
|
|
<div className="flex flex-col h-full">
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-slate-200 dark:border-slate-800 bg-slate-50/80 dark:bg-slate-950/50 backdrop-blur-sm sticky top-0 z-10">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<h2 className="text-xl font-bold text-slate-900 dark:text-white leading-tight">{data.name}</h2>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleExport}
|
|
className="p-1.5 text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
title="Export JSON"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => fetchData(true)}
|
|
className="p-1.5 text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors"
|
|
title="Refresh"
|
|
>
|
|
<RefreshCwIcon className={clsx("h-4 w-4", (loading || isProcessing) && "animate-spin")} />
|
|
</button>
|
|
<button onClick={onClose} className="p-1.5 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors">
|
|
<X className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 text-sm items-center">
|
|
{!isEditingWebsite ? (
|
|
<div className="flex items-center gap-2">
|
|
{data.website && data.website !== "k.A." ? (
|
|
<a href={data.website} target="_blank" className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors font-medium">
|
|
<ExternalLink className="h-3 w-3" /> {new URL(data.website).hostname.replace('www.', '')}
|
|
</a>
|
|
) : (
|
|
<span className="text-slate-500 italic">No website</span>
|
|
)}
|
|
<button
|
|
onClick={() => { setWebsiteInput(data.website && data.website !== "k.A." ? data.website : ""); setIsEditingWebsite(true); }}
|
|
className="p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
|
title="Edit Website URL"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1 animate-in fade-in zoom-in duration-200">
|
|
<input
|
|
type="text"
|
|
value={websiteInput}
|
|
onChange={e => setWebsiteInput(e.target.value)}
|
|
placeholder="https://..."
|
|
className="bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded px-2 py-0.5 text-xs text-slate-900 dark:text-white focus:ring-1 focus:ring-blue-500 outline-none w-48"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={handleWebsiteOverride}
|
|
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsEditingWebsite(false)}
|
|
className="p-1 text-slate-500 hover:text-red-500 transition-colors"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{data.industry_ai && (
|
|
<span className="flex items-center gap-1 px-2 py-0.5 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded border border-slate-200 dark:border-slate-700">
|
|
<Briefcase className="h-3 w-3" /> {data.industry_ai}
|
|
</span>
|
|
)}
|
|
<span className={clsx(
|
|
"px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider",
|
|
data.status === 'ENRICHED' ? "bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800/50" :
|
|
data.status === 'DISCOVERED' ? "bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800/50" :
|
|
"bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700"
|
|
)}>
|
|
{data.status}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="mt-6 flex border-b border-slate-200 dark:border-slate-800">
|
|
<button
|
|
onClick={() => setActiveTab('overview')}
|
|
className={clsx(
|
|
"px-4 py-2 text-sm font-medium transition-colors border-b-2",
|
|
activeTab === 'overview'
|
|
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
|
: "border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200"
|
|
)}
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('contacts')}
|
|
className={clsx(
|
|
"px-4 py-2 text-sm font-medium transition-colors border-b-2 flex items-center gap-2",
|
|
activeTab === 'contacts'
|
|
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
|
: "border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200"
|
|
)}
|
|
>
|
|
Contacts
|
|
{data.contacts && data.contacts.length > 0 && (
|
|
<span className="bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300 px-1.5 py-0.5 rounded-full text-[10px] min-w-[1.25rem] text-center">
|
|
{data.contacts.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-8 bg-white dark:bg-slate-900">
|
|
|
|
{activeTab === 'overview' && (
|
|
<>
|
|
{/* Action Bar (Only for Overview) */}
|
|
<div className="flex gap-2 mb-6">
|
|
<button
|
|
onClick={handleDiscover}
|
|
disabled={isProcessing}
|
|
className="flex-1 flex items-center justify-center gap-2 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-50 text-slate-700 dark:text-white text-xs font-bold py-2 rounded-md border border-slate-200 dark:border-slate-700 transition-all shadow-sm"
|
|
>
|
|
<SearchIcon className="h-3.5 w-3.5" />
|
|
{isProcessing ? "Processing..." : "DISCOVER"}
|
|
</button>
|
|
<button
|
|
onClick={handleAnalyze}
|
|
disabled={isProcessing || !data.website || data.website === 'k.A.'}
|
|
className="flex-1 flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-xs font-bold py-2 rounded-md transition-all shadow-lg shadow-blue-900/20"
|
|
>
|
|
<Bot className="h-3.5 w-3.5" />
|
|
{isProcessing ? "Analyzing..." : "ANALYZE POTENTIAL"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Impressum / Legal Data */}
|
|
<div className="bg-slate-50 dark:bg-slate-950 rounded-lg p-4 border border-slate-200 dark:border-slate-800 flex flex-col gap-2">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1 bg-white dark:bg-slate-800 rounded text-slate-400">
|
|
<Briefcase className="h-3 w-3" />
|
|
</div>
|
|
<span className="text-[10px] uppercase font-bold text-slate-500 tracking-wider">Official Legal Data</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{scrapeDate && (
|
|
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
|
<Clock className="h-3 w-3" /> {new Date(scrapeDate).toLocaleDateString()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Lock Button for Impressum */}
|
|
{scrapeEntry && (
|
|
<button
|
|
onClick={() => handleLockToggle('website_scrape', scrapeEntry.is_locked || false)}
|
|
className={clsx(
|
|
"p-1 rounded transition-colors",
|
|
scrapeEntry.is_locked
|
|
? "text-green-600 dark:text-green-400 hover:text-green-700"
|
|
: "text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
|
)}
|
|
title={scrapeEntry.is_locked ? "Data Locked (Safe from auto-overwrite)" : "Unlocked (Auto-overwrite enabled)"}
|
|
>
|
|
{scrapeEntry.is_locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
|
</button>
|
|
)}
|
|
|
|
{!isEditingImpressum ? (
|
|
<button
|
|
onClick={() => { setImpressumUrlInput(""); setIsEditingImpressum(true); }}
|
|
className="p-1 text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors"
|
|
title="Set Impressum URL Manually"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
) : ( <div className="flex items-center gap-1 animate-in fade-in zoom-in duration-200">
|
|
<button
|
|
onClick={handleImpressumOverride}
|
|
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsEditingImpressum(false)}
|
|
className="p-1 text-slate-500 hover:text-red-500 transition-colors"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isEditingImpressum && (
|
|
<div className="mb-2 animate-in slide-in-from-top-1 duration-200">
|
|
<input
|
|
type="text"
|
|
value={impressumUrlInput}
|
|
onChange={e => setImpressumUrlInput(e.target.value)}
|
|
placeholder="https://.../impressum"
|
|
className="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-2 py-1 text-xs text-slate-900 dark:text-white focus:ring-1 focus:ring-blue-500 outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{impressum ? (
|
|
<>
|
|
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
|
{impressum.legal_name || "Unknown Legal Name"}
|
|
</div>
|
|
|
|
<div className="flex items-start gap-2 text-xs text-slate-500 dark:text-slate-400">
|
|
<MapPin className="h-3 w-3 mt-0.5 shrink-0" />
|
|
<div>
|
|
<div>{impressum.street}</div>
|
|
<div>{impressum.zip} {impressum.city}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{(impressum.email || impressum.phone) && (
|
|
<div className="mt-2 pt-2 border-t border-slate-200 dark:border-slate-900 flex flex-wrap gap-4 text-[10px] text-slate-500 font-mono">
|
|
{impressum.email && <span>{impressum.email}</span>}
|
|
{impressum.phone && <span>{impressum.phone}</span>}
|
|
{impressum.vat_id && <span className="text-blue-600 dark:text-blue-400/80">VAT: {impressum.vat_id}</span>}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : !isEditingImpressum && (
|
|
<div className="text-[10px] text-slate-500 italic py-2">
|
|
No legal data found. Click pencil to provide direct Impressum link.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
|
|
|
|
{/* AI Analysis Dossier */}
|
|
{aiAnalysis && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
|
<Bot className="h-4 w-4" /> AI Strategic Dossier
|
|
</h3>
|
|
{aiDate && (
|
|
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
|
<Clock className="h-3 w-3" /> {new Date(aiDate).toLocaleDateString()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="bg-white dark:bg-slate-800/30 rounded-xl p-5 border border-slate-200 dark:border-slate-800/50 space-y-4 shadow-sm">
|
|
<div>
|
|
<div className="text-[10px] text-blue-600 dark:text-blue-400 uppercase font-bold tracking-tight mb-1">Business Model</div>
|
|
<p className="text-sm text-slate-700 dark:text-slate-200 leading-relaxed">{aiAnalysis.business_model || "No summary available."}</p>
|
|
</div>
|
|
{aiAnalysis.infrastructure_evidence && (
|
|
<div className="pt-4 border-t border-slate-200 dark:border-slate-800/50">
|
|
<div className="text-[10px] text-orange-600 dark:text-orange-400 uppercase font-bold tracking-tight mb-1">Infrastructure Evidence</div>
|
|
<p className="text-sm text-slate-600 dark:text-slate-300 italic leading-relaxed">"{aiAnalysis.infrastructure_evidence}"</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Wikipedia Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
|
<Globe className="h-4 w-4" /> Company Profile (Wikipedia)
|
|
</h3>
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
{wikiDate && (
|
|
|
|
<div className="text-[10px] text-slate-500 flex items-center gap-1 mr-2">
|
|
|
|
<Clock className="h-3 w-3" /> {new Date(wikiDate).toLocaleDateString()}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* Lock Button for Wiki */}
|
|
|
|
{wikiEntry && (
|
|
|
|
<button
|
|
|
|
onClick={() => handleLockToggle('wikipedia', wikiEntry.is_locked || false)}
|
|
|
|
className={clsx(
|
|
|
|
"p-1 rounded transition-colors mr-1",
|
|
|
|
wikiEntry.is_locked
|
|
|
|
? "text-green-600 dark:text-green-400 hover:text-green-700"
|
|
|
|
: "text-slate-400 hover:text-slate-900 dark:hover:text-white"
|
|
|
|
)}
|
|
|
|
title={wikiEntry.is_locked ? "Wiki Data Locked" : "Wiki Data Unlocked"}
|
|
|
|
>
|
|
|
|
{wikiEntry.is_locked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{!isEditingWiki ? (
|
|
|
|
<button
|
|
|
|
onClick={() => { setWikiUrlInput(wiki?.url || ""); setIsEditingWiki(true); }}
|
|
|
|
className="p-1 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
|
|
title="Edit / Override URL"
|
|
|
|
>
|
|
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
|
|
</button>
|
|
|
|
) : ( <div className="flex items-center gap-1">
|
|
<button
|
|
onClick={handleWikiOverride}
|
|
className="p-1 bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400 rounded hover:bg-green-200 dark:hover:bg-green-900 transition-colors"
|
|
title="Save & Rescan"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsEditingWiki(false)}
|
|
className="p-1 text-slate-500 hover:text-red-500 transition-colors"
|
|
title="Cancel"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isEditingWiki && (
|
|
<div className="mb-2">
|
|
<input
|
|
type="text"
|
|
value={wikiUrlInput}
|
|
onChange={e => setWikiUrlInput(e.target.value)}
|
|
placeholder="Paste Wikipedia URL here..."
|
|
className="w-full bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded px-2 py-1 text-sm text-slate-900 dark:text-white focus:ring-1 focus:ring-blue-500 outline-none"
|
|
/>
|
|
<p className="text-[10px] text-slate-500 mt-1">Paste a valid URL. Saving will trigger a re-scan.</p>
|
|
</div>
|
|
)}
|
|
|
|
{wiki && wiki.url !== 'k.A.' && !isEditingWiki ? (
|
|
<div>
|
|
{/* ... existing wiki content ... */}
|
|
<div className="bg-white dark:bg-slate-800/30 rounded-xl p-5 border border-slate-200 dark:border-slate-800/50 relative overflow-hidden shadow-sm">
|
|
<div className="absolute top-0 right-0 p-3 opacity-10">
|
|
<Globe className="h-16 w-16 text-slate-900 dark:text-white" />
|
|
</div>
|
|
|
|
{isLocked && (
|
|
<div className="absolute top-2 right-2 flex items-center gap-1 px-1.5 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800/50 rounded text-[9px] text-yellow-600 dark:text-yellow-500">
|
|
<Tag className="h-2.5 w-2.5" /> Manual Override
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-sm text-slate-600 dark:text-slate-300 leading-relaxed italic mb-4">
|
|
"{wiki.first_paragraph}"
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-y-4 gap-x-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-slate-100 dark:bg-slate-900 rounded-lg text-blue-500">
|
|
<Users className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Employees</div>
|
|
<div className="text-sm text-slate-700 dark:text-slate-200 font-medium">{wiki.mitarbeiter || 'k.A.'}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-slate-100 dark:bg-slate-900 rounded-lg text-green-500">
|
|
<DollarSign className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Revenue</div>
|
|
<div className="text-sm text-slate-700 dark:text-slate-200 font-medium">{wiki.umsatz && wiki.umsatz !== 'k.A.' ? `${wiki.umsatz} Mio. €` : 'k.A.'}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-slate-100 dark:bg-slate-900 rounded-lg text-orange-500">
|
|
<MapPin className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Headquarters</div>
|
|
<div className="text-sm text-slate-700 dark:text-slate-200 font-medium">{wiki.sitz_stadt}{wiki.sitz_land ? `, ${wiki.sitz_land}` : ''}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-slate-100 dark:bg-slate-900 rounded-lg text-purple-500">
|
|
<Briefcase className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Wiki Industry</div>
|
|
<div className="text-sm text-slate-700 dark:text-slate-200 font-medium truncate max-w-[150px]" title={wiki.branche}>{wiki.branche || 'k.A.'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{wiki.categories && wiki.categories !== 'k.A.' && (
|
|
<div className="mt-6 pt-5 border-t border-slate-200 dark:border-slate-800/50">
|
|
<div className="flex items-start gap-2 text-xs text-slate-500 mb-2">
|
|
<Tag className="h-3 w-3 mt-0.5" /> Categories
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{wiki.categories.split(',').map((cat: string) => (
|
|
<span key={cat} className="px-2 py-0.5 bg-slate-100 dark:bg-slate-900 text-slate-600 dark:text-slate-400 rounded-full text-[10px] border border-slate-200 dark:border-slate-800">
|
|
{cat.trim()}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex justify-end">
|
|
<a href={wiki.url} target="_blank" className="text-[10px] text-blue-600 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 flex items-center gap-1 font-bold">
|
|
WIKIPEDIA <ExternalLink className="h-2.5 w-2.5" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : !isEditingWiki ? (
|
|
<div className="p-4 rounded-xl border border-dashed border-slate-200 dark:border-slate-800 text-center text-slate-500 dark:text-slate-600">
|
|
<Globe className="h-5 w-5 mx-auto mb-2 opacity-20" />
|
|
<p className="text-xs">No Wikipedia profile found yet.</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Robotics Scorecard */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-2">
|
|
<Bot className="h-4 w-4" /> Robotics Potential
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{['cleaning', 'transport', 'security', 'service'].map(type => {
|
|
const sig = data.signals.find(s => s.signal_type.includes(type))
|
|
const score = sig ? sig.confidence : 0
|
|
|
|
return (
|
|
<div key={type} className="bg-slate-50 dark:bg-slate-800/50 p-3 rounded-lg border border-slate-200 dark:border-slate-700">
|
|
<div className="flex justify-between mb-1">
|
|
<span className="text-sm text-slate-700 dark:text-slate-300 capitalize">{type}</span>
|
|
<span className={clsx("text-sm font-bold", score > 70 ? "text-green-600 dark:text-green-400" : score > 30 ? "text-yellow-600 dark:text-yellow-400" : "text-slate-500")}>
|
|
{score}%
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-slate-200 dark:bg-slate-700 h-1.5 rounded-full overflow-hidden">
|
|
<div
|
|
className={clsx("h-full rounded-full", score > 70 ? "bg-green-500" : score > 30 ? "bg-yellow-500" : "bg-slate-500")}
|
|
style={{ width: `${score}%` }}
|
|
/>
|
|
</div>
|
|
{sig?.proof_text && (
|
|
<p className="text-xs text-slate-500 dark:text-slate-500 mt-2 line-clamp-2" title={sig.proof_text}>
|
|
"{sig.proof_text}"
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta Info */}
|
|
<div className="pt-6 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between">
|
|
<div className="text-[10px] text-slate-500 flex items-center gap-2 uppercase font-bold tracking-widest">
|
|
<Calendar className="h-3 w-3" /> Added: {new Date(data.created_at).toLocaleDateString()}
|
|
</div>
|
|
<div className="text-[10px] text-slate-400 dark:text-slate-600 italic">
|
|
ID: CE-{data.id.toString().padStart(4, '0')}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'contacts' && (
|
|
<ContactsManager
|
|
contacts={data.contacts}
|
|
initialContactId={initialContactId}
|
|
onAddContact={handleAddContact}
|
|
onEditContact={handleEditContact}
|
|
/>
|
|
)} </div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|