feat(company-explorer): Initial Web UI & Backend with Enrichment Flow

This commit introduces the foundational elements for the new "Company Explorer" web application, marking a significant step away from the legacy Google Sheets / CLI system.

Key changes include:
- Project Structure: A new  directory with separate  (FastAPI) and  (React/Vite) components.
- Data Persistence: Migration from Google Sheets to a local SQLite database () using SQLAlchemy.
- Core Utilities: Extraction and cleanup of essential helper functions (LLM wrappers, text utilities) into .
- Backend Services: , ,  for AI-powered analysis, and  logic.
- Frontend UI: Basic React application with company table, import wizard, and dynamic inspector sidebar.
- Docker Integration: Updated  and  for multi-stage builds and sideloading.
- Deployment & Access: Integrated into central Nginx proxy and dashboard, accessible via .

Lessons Learned & Fixed during development:
- Frontend Asset Loading: Addressed issues with Vite's  path and FastAPI's .
- TypeScript Configuration: Added  and .
- Database Schema Evolution: Solved  errors by forcing a new database file and correcting  override.
- Logging: Implemented robust file-based logging ().

This new foundation provides a powerful and maintainable platform for future B2B robotics lead generation.
This commit is contained in:
2026-01-07 17:55:08 +00:00
parent 7405c2acb9
commit 2c7bb262ef
51 changed files with 3475 additions and 2 deletions

View File

@@ -0,0 +1,205 @@
import { useState, useEffect, useMemo } from 'react'
import {
useReactTable,
getCoreRowModel,
flexRender,
createColumnHelper,
} from '@tanstack/react-table'
import axios from 'axios'
import { Play, Globe, AlertCircle, Search as SearchIcon, Loader2 } from 'lucide-react'
import clsx from 'clsx'
type Company = {
id: number
name: string
city: string | null
country: string
website: string | null
status: string
industry_ai: string | null
}
const columnHelper = createColumnHelper<Company>()
interface CompanyTableProps {
apiBase: string
onRowClick: (companyId: number) => void // NEW PROP
}
export function CompanyTable({ apiBase, onRowClick }: CompanyTableProps) {
const [data, setData] = useState<Company[]>([])
const [loading, setLoading] = useState(true)
const [processingId, setProcessingId] = useState<number | null>(null)
const fetchData = async () => {
setLoading(true)
try {
const res = await axios.get(`${apiBase}/companies?limit=100`)
setData(res.data.items)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
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)
}
}
const triggerAnalysis = async (id: number) => {
setProcessingId(id)
try {
await axios.post(`${apiBase}/enrich/analyze`, { company_id: id })
setTimeout(fetchData, 2000)
} catch (e) {
alert("Analysis Error")
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>
)
}

View File

@@ -0,0 +1,85 @@
import { useState } from 'react'
import axios from 'axios'
import { X, UploadCloud } from 'lucide-react'
interface ImportWizardProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
apiBase: string
}
export function ImportWizard({ isOpen, onClose, onSuccess, apiBase }: ImportWizardProps) {
const [text, setText] = useState("")
const [loading, setLoading] = useState(false)
if (!isOpen) return null
const handleImport = async () => {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0)
if (lines.length === 0) return
setLoading(true)
try {
await axios.post(`${apiBase}/companies/bulk`, { names: lines })
setText("")
onSuccess()
onClose()
} catch (e: any) {
console.error(e)
const msg = e.response?.data?.detail || e.message || "Unknown Error"
alert(`Import failed: ${msg}`)
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<UploadCloud className="h-5 w-5 text-blue-400" />
Quick Import
</h3>
<button onClick={onClose} className="text-slate-400 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="p-4 space-y-4">
<p className="text-sm text-slate-400">
Paste company names below (one per line). Duplicates in the database will be skipped automatically.
</p>
<textarea
className="w-full h-64 bg-slate-950 border border-slate-700 rounded-lg p-3 text-sm text-slate-200 focus:ring-2 focus:ring-blue-600 outline-none font-mono"
placeholder="Company A&#10;Company B&#10;Company C..."
value={text}
onChange={e => setText(e.target.value)}
/>
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-800 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-400 hover:text-white"
>
Cancel
</button>
<button
onClick={handleImport}
disabled={loading || !text.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-md text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Importing..." : "Import Companies"}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { X, ExternalLink, Robot, Briefcase, Calendar } from 'lucide-react'
import clsx from 'clsx'
interface InspectorProps {
companyId: number | null
onClose: () => void
apiBase: string
}
type Signal = {
signal_type: string
confidence: number
value: string
proof_text: string
}
type CompanyDetail = {
id: number
name: string
website: string | null
industry_ai: string | null
status: string
created_at: string
signals: Signal[]
}
export function Inspector({ companyId, onClose, apiBase }: InspectorProps) {
const [data, setData] = useState<CompanyDetail | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!companyId) return
setLoading(true)
axios.get(`${apiBase}/companies/${companyId}`)
.then(res => setData(res.data))
.catch(console.error)
.finally(() => setLoading(false))
}, [companyId])
if (!companyId) return null
return (
<div className="fixed inset-y-0 right-0 w-[500px] bg-slate-900 border-l border-slate-800 shadow-2xl transform transition-transform duration-300 ease-in-out z-40 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-800 bg-slate-950/50">
<div className="flex justify-between items-start mb-4">
<h2 className="text-xl font-bold text-white leading-tight">{data.name}</h2>
<button onClick={onClose} className="text-slate-400 hover:text-white">
<X className="h-6 w-6" />
</button>
</div>
<div className="flex flex-wrap gap-2 text-sm">
{data.website && (
<a href={data.website} target="_blank" className="flex items-center gap-1 text-blue-400 hover:underline">
<ExternalLink className="h-3 w-3" /> {new URL(data.website).hostname.replace('www.', '')}
</a>
)}
{data.industry_ai && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-slate-800 text-slate-300 rounded border border-slate-700">
<Briefcase className="h-3 w-3" /> {data.industry_ai}
</span>
)}
</div>
</div>
{/* Robotics Scorecard */}
<div className="p-6 space-y-6">
<div>
<h3 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Robot 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-800/50 p-3 rounded-lg border border-slate-700">
<div className="flex justify-between mb-1">
<span className="text-sm text-slate-300 capitalize">{type}</span>
<span className={clsx("text-sm font-bold", score > 70 ? "text-green-400" : score > 30 ? "text-yellow-400" : "text-slate-500")}>
{score}%
</span>
</div>
<div className="w-full 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-600")}
style={{ width: `${score}%` }}
/>
</div>
{sig?.proof_text && (
<p className="text-xs 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-800">
<div className="text-xs text-slate-500 flex items-center gap-2">
<Calendar className="h-3 w-3" /> Added: {new Date(data.created_at).toLocaleDateString()}
</div>
</div>
</div>
</div>
)}
</div>
)
}