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:
12
company-explorer/frontend/index.html
Normal file
12
company-explorer/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Company Explorer (Robotics)</title>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
company-explorer/frontend/package.json
Normal file
31
company-explorer/frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "company-explorer-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.4",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
6
company-explorer/frontend/postcss.config.js
Normal file
6
company-explorer/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
116
company-explorer/frontend/src/App.tsx
Normal file
116
company-explorer/frontend/src/App.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { CompanyTable } from './components/CompanyTable'
|
||||
import { ImportWizard } from './components/ImportWizard'
|
||||
import { Inspector } from './components/Inspector' // NEW
|
||||
import { LayoutDashboard, UploadCloud, Search, RefreshCw } from 'lucide-react'
|
||||
|
||||
// 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 [selectedCompanyId, setSelectedCompanyId] = useState<number | null>(null) // NEW
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [refreshKey])
|
||||
|
||||
const handleCompanySelect = (id: number) => {
|
||||
setSelectedCompanyId(id)
|
||||
}
|
||||
|
||||
const handleCloseInspector = () => {
|
||||
setSelectedCompanyId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-200 font-sans">
|
||||
<ImportWizard
|
||||
isOpen={isImportOpen}
|
||||
onClose={() => setIsImportOpen(false)}
|
||||
apiBase={API_BASE}
|
||||
onSuccess={() => setRefreshKey(k => k + 1)}
|
||||
/>
|
||||
|
||||
{/* Inspector Sidebar */}
|
||||
<Inspector
|
||||
companyId={selectedCompanyId}
|
||||
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">
|
||||
<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.2.2 (New DB Path)</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>
|
||||
|
||||
<button
|
||||
onClick={() => setRefreshKey(k => k + 1)}
|
||||
className="p-2 hover:bg-slate-800 rounded-full transition-colors text-slate-400 hover:text-white"
|
||||
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>
|
||||
</div>
|
||||
</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 */}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
205
company-explorer/frontend/src/components/CompanyTable.tsx
Normal file
205
company-explorer/frontend/src/components/CompanyTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
company-explorer/frontend/src/components/ImportWizard.tsx
Normal file
85
company-explorer/frontend/src/components/ImportWizard.tsx
Normal 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 Company B 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>
|
||||
)
|
||||
}
|
||||
123
company-explorer/frontend/src/components/Inspector.tsx
Normal file
123
company-explorer/frontend/src/components/Inspector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
company-explorer/frontend/src/index.css
Normal file
19
company-explorer/frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom Scrollbar for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
10
company-explorer/frontend/src/main.tsx
Normal file
10
company-explorer/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
1
company-explorer/frontend/src/vite-env.d.ts
vendored
Normal file
1
company-explorer/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
11
company-explorer/frontend/tailwind.config.js
Normal file
11
company-explorer/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
16
company-explorer/frontend/vite.config.ts
Normal file
16
company-explorer/frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/ce/', // Critical for Nginx Reverse Proxy
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000', // Forward API calls to FastAPI during dev
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user