feat(ce): upgrade to v0.5.0 with contacts management, advanced settings and ui modernization
This commit is contained in:
@@ -17,7 +17,7 @@ setup_logging()
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory
|
||||
from .database import init_db, get_db, Company, Signal, EnrichmentData, RoboticsCategory, Contact, Industry, JobRoleMapping
|
||||
from .services.deduplication import Deduplicator
|
||||
from .services.discovery import DiscoveryService
|
||||
from .services.scraping import ScraperService
|
||||
@@ -58,6 +58,33 @@ class AnalysisRequest(BaseModel):
|
||||
company_id: int
|
||||
force_scrape: bool = False
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
gender: str
|
||||
title: str = ""
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
job_title: str
|
||||
language: str = "De"
|
||||
role: str
|
||||
status: str = ""
|
||||
is_primary: bool = False
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
company_id: int
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
gender: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
job_title: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
is_primary: Optional[bool] = None
|
||||
|
||||
# --- Events ---
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
@@ -99,7 +126,8 @@ def list_companies(
|
||||
def get_company(company_id: int, db: Session = Depends(get_db)):
|
||||
company = db.query(Company).options(
|
||||
joinedload(Company.signals),
|
||||
joinedload(Company.enrichment_data)
|
||||
joinedload(Company.enrichment_data),
|
||||
joinedload(Company.contacts)
|
||||
).filter(Company.id == company_id).first()
|
||||
if not company:
|
||||
raise HTTPException(status_code=404, detail="Company not found")
|
||||
@@ -300,6 +328,230 @@ def override_impressum_url(company_id: int, url: str = Query(...), db: Session =
|
||||
db.commit()
|
||||
return {"status": "updated", "data": impressum_data}
|
||||
|
||||
# --- Contact Routes ---
|
||||
|
||||
@app.post("/api/contacts")
|
||||
def create_contact(contact: ContactCreate, db: Session = Depends(get_db)):
|
||||
"""Creates a new contact and handles primary contact logic."""
|
||||
if contact.is_primary:
|
||||
db.query(Contact).filter(Contact.company_id == contact.company_id).update({"is_primary": False})
|
||||
|
||||
db_contact = Contact(**contact.dict())
|
||||
db.add(db_contact)
|
||||
db.commit()
|
||||
db.refresh(db_contact)
|
||||
return db_contact
|
||||
|
||||
# --- Industry Routes ---
|
||||
|
||||
class IndustryCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
is_focus: bool = False
|
||||
primary_category_id: Optional[int] = None
|
||||
|
||||
class IndustryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_focus: Optional[bool] = None
|
||||
primary_category_id: Optional[int] = None
|
||||
|
||||
@app.get("/api/industries")
|
||||
def list_industries(db: Session = Depends(get_db)):
|
||||
return db.query(Industry).all()
|
||||
|
||||
@app.post("/api/industries")
|
||||
def create_industry(ind: IndustryCreate, db: Session = Depends(get_db)):
|
||||
# 1. Prepare data
|
||||
ind_data = ind.dict()
|
||||
base_name = ind_data['name']
|
||||
|
||||
# 2. Check for duplicate name
|
||||
existing = db.query(Industry).filter(Industry.name == base_name).first()
|
||||
if existing:
|
||||
# Auto-increment name if duplicated
|
||||
counter = 1
|
||||
while db.query(Industry).filter(Industry.name == f"{base_name} ({counter})").first():
|
||||
counter += 1
|
||||
ind_data['name'] = f"{base_name} ({counter})"
|
||||
|
||||
# 3. Create
|
||||
db_ind = Industry(**ind_data)
|
||||
db.add(db_ind)
|
||||
db.commit()
|
||||
db.refresh(db_ind)
|
||||
return db_ind
|
||||
|
||||
@app.put("/api/industries/{id}")
|
||||
def update_industry(id: int, ind: IndustryUpdate, db: Session = Depends(get_db)):
|
||||
db_ind = db.query(Industry).filter(Industry.id == id).first()
|
||||
if not db_ind:
|
||||
raise HTTPException(404, "Industry not found")
|
||||
|
||||
for key, value in ind.dict(exclude_unset=True).items():
|
||||
setattr(db_ind, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_ind)
|
||||
return db_ind
|
||||
|
||||
@app.delete("/api/industries/{id}")
|
||||
def delete_industry(id: int, db: Session = Depends(get_db)):
|
||||
db_ind = db.query(Industry).filter(Industry.id == id).first()
|
||||
if not db_ind:
|
||||
raise HTTPException(404, "Industry not found")
|
||||
db.delete(db_ind)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
# --- Job Role Mapping Routes ---
|
||||
|
||||
class JobRoleMappingCreate(BaseModel):
|
||||
pattern: str
|
||||
role: str
|
||||
|
||||
@app.get("/api/job_roles")
|
||||
def list_job_roles(db: Session = Depends(get_db)):
|
||||
return db.query(JobRoleMapping).all()
|
||||
|
||||
@app.post("/api/job_roles")
|
||||
def create_job_role(mapping: JobRoleMappingCreate, db: Session = Depends(get_db)):
|
||||
db_mapping = JobRoleMapping(**mapping.dict())
|
||||
db.add(db_mapping)
|
||||
db.commit()
|
||||
db.refresh(db_mapping)
|
||||
return db_mapping
|
||||
|
||||
@app.delete("/api/job_roles/{id}")
|
||||
def delete_job_role(id: int, db: Session = Depends(get_db)):
|
||||
db_mapping = db.query(JobRoleMapping).filter(JobRoleMapping.id == id).first()
|
||||
if not db_mapping:
|
||||
raise HTTPException(404, "Mapping not found")
|
||||
db.delete(db_mapping)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
@app.put("/api/contacts/{contact_id}")
|
||||
def update_contact(contact_id: int, contact: ContactUpdate, db: Session = Depends(get_db)):
|
||||
"""Updates an existing contact."""
|
||||
db_contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
||||
if not db_contact:
|
||||
raise HTTPException(404, "Contact not found")
|
||||
|
||||
update_data = contact.dict(exclude_unset=True)
|
||||
|
||||
if update_data.get("is_primary"):
|
||||
db.query(Contact).filter(Contact.company_id == db_contact.company_id).update({"is_primary": False})
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(db_contact, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_contact)
|
||||
return db_contact
|
||||
|
||||
@app.delete("/api/contacts/{contact_id}")
|
||||
def delete_contact(contact_id: int, db: Session = Depends(get_db)):
|
||||
"""Deletes a contact."""
|
||||
db_contact = db.query(Contact).filter(Contact.id == contact_id).first()
|
||||
if not db_contact:
|
||||
raise HTTPException(404, "Contact not found")
|
||||
db.delete(db_contact)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
@app.get("/api/contacts/all")
|
||||
def list_all_contacts(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Lists all contacts across all companies with pagination and search.
|
||||
"""
|
||||
query = db.query(Contact).join(Company)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Contact.first_name.ilike(search_term)) |
|
||||
(Contact.last_name.ilike(search_term)) |
|
||||
(Contact.email.ilike(search_term)) |
|
||||
(Company.name.ilike(search_term))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
# Sort by ID desc
|
||||
contacts = query.order_by(Contact.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
# Enrich with Company Name for the frontend list
|
||||
result = []
|
||||
for c in contacts:
|
||||
c_dict = {k: v for k, v in c.__dict__.items() if not k.startswith('_')}
|
||||
c_dict['company_name'] = c.company.name if c.company else "Unknown"
|
||||
result.append(c_dict)
|
||||
|
||||
return {"total": total, "items": result}
|
||||
|
||||
class BulkContactImportItem(BaseModel):
|
||||
company_name: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: Optional[str] = None
|
||||
job_title: Optional[str] = None
|
||||
role: Optional[str] = "Operativer Entscheider"
|
||||
gender: Optional[str] = "männlich"
|
||||
|
||||
class BulkContactImportRequest(BaseModel):
|
||||
contacts: List[BulkContactImportItem]
|
||||
|
||||
@app.post("/api/contacts/bulk")
|
||||
def bulk_import_contacts(req: BulkContactImportRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Bulk imports contacts.
|
||||
Matches Company by Name (creates if missing).
|
||||
Dedupes Contact by Email.
|
||||
"""
|
||||
logger.info(f"Starting bulk contact import: {len(req.contacts)} items")
|
||||
stats = {"added": 0, "skipped": 0, "companies_created": 0}
|
||||
|
||||
for item in req.contacts:
|
||||
if not item.company_name: continue
|
||||
|
||||
# 1. Find or Create Company
|
||||
company = db.query(Company).filter(Company.name.ilike(item.company_name.strip())).first()
|
||||
if not company:
|
||||
company = Company(name=item.company_name.strip(), status="NEW")
|
||||
db.add(company)
|
||||
db.commit() # Commit to get ID
|
||||
db.refresh(company)
|
||||
stats["companies_created"] += 1
|
||||
|
||||
# 2. Check for Duplicate Contact (by Email)
|
||||
if item.email:
|
||||
exists = db.query(Contact).filter(Contact.email == item.email.strip()).first()
|
||||
if exists:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# 3. Create Contact
|
||||
new_contact = Contact(
|
||||
company_id=company.id,
|
||||
first_name=item.first_name,
|
||||
last_name=item.last_name,
|
||||
email=item.email,
|
||||
job_title=item.job_title,
|
||||
role=item.role,
|
||||
gender=item.gender,
|
||||
status="Init" # Default status
|
||||
)
|
||||
db.add(new_contact)
|
||||
stats["added"] += 1
|
||||
|
||||
db.commit()
|
||||
return stats
|
||||
|
||||
def run_discovery_task(company_id: int):
|
||||
# New Session for Background Task
|
||||
from .database import SessionLocal
|
||||
|
||||
@@ -46,6 +46,65 @@ class Company(Base):
|
||||
# Relationships
|
||||
signals = relationship("Signal", back_populates="company", cascade="all, delete-orphan")
|
||||
enrichment_data = relationship("EnrichmentData", back_populates="company", cascade="all, delete-orphan")
|
||||
contacts = relationship("Contact", back_populates="company", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
"""
|
||||
Represents a person associated with a company.
|
||||
"""
|
||||
__tablename__ = "contacts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
company_id = Column(Integer, ForeignKey("companies.id"), index=True)
|
||||
|
||||
gender = Column(String) # "männlich", "weiblich"
|
||||
title = Column(String, default="") # "Dr.", "Prof."
|
||||
first_name = Column(String)
|
||||
last_name = Column(String)
|
||||
email = Column(String, index=True)
|
||||
job_title = Column(String) # Visitenkarten-Titel
|
||||
language = Column(String, default="De") # "De", "En"
|
||||
|
||||
role = Column(String) # Operativer Entscheider, etc.
|
||||
status = Column(String, default="") # Marketing Status
|
||||
|
||||
is_primary = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
company = relationship("Company", back_populates="contacts")
|
||||
|
||||
|
||||
class Industry(Base):
|
||||
"""
|
||||
Represents a specific industry vertical (Branche).
|
||||
"""
|
||||
__tablename__ = "industries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, unique=True, index=True)
|
||||
description = Column(Text, nullable=True) # Abgrenzung
|
||||
is_focus = Column(Boolean, default=False)
|
||||
|
||||
# Optional link to a Robotics Category (the "product" relevant for this industry)
|
||||
primary_category_id = Column(Integer, ForeignKey("robotics_categories.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class JobRoleMapping(Base):
|
||||
"""
|
||||
Maps job title patterns (regex or simple string) to Roles.
|
||||
"""
|
||||
__tablename__ = "job_role_mappings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
pattern = Column(String, unique=True) # e.g. "%CTO%" or "Technischer Leiter"
|
||||
role = Column(String) # The target Role
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Signal(Base):
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user