[30388f42] Infrastructure Hardening: Repaired CE/Connector DB schema, fixed frontend styling build, implemented robust echo shield in worker v2.1.1, and integrated Lead Engine into gateway.
This commit is contained in:
@@ -4,7 +4,7 @@ import { ContactsTable } from './components/ContactsTable' // NEW
|
||||
import { ImportWizard } from './components/ImportWizard'
|
||||
import { Inspector } from './components/Inspector'
|
||||
import { RoboticsSettings } from './components/RoboticsSettings'
|
||||
import { LayoutDashboard, UploadCloud, RefreshCw, Settings, Users, Building, Sun, Moon } from 'lucide-react'
|
||||
import { LayoutDashboard, UploadCloud, RefreshCw, Settings, Users, Building, Sun, Moon, Activity } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// Base URL detection (Production vs Dev)
|
||||
@@ -119,6 +119,16 @@ function App() {
|
||||
{theme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/connector/dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400"
|
||||
title="Connector Status Dashboard"
|
||||
>
|
||||
<Activity className="h-5 w-5" />
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full transition-colors text-slate-500 dark:text-slate-400"
|
||||
|
||||
@@ -57,6 +57,10 @@ type CompanyDetail = {
|
||||
// Industry Strategy (V2)
|
||||
industry_details?: IndustryDetails
|
||||
|
||||
// Marketing AI (V3)
|
||||
ai_opener: string | null
|
||||
ai_opener_secondary: string | null
|
||||
|
||||
// NEU v0.7.0: Quantitative Metrics
|
||||
calculated_metric_name: string | null
|
||||
calculated_metric_value: number | null
|
||||
@@ -453,6 +457,43 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
)
|
||||
}
|
||||
|
||||
// Marketing AI Card Renderer
|
||||
const renderMarketingCard = () => {
|
||||
if (!data?.ai_opener && !data?.ai_opener_secondary) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/10 rounded-xl p-5 border border-orange-100 dark:border-orange-900/50 mb-6">
|
||||
<h3 className="text-sm font-semibold text-orange-700 dark:text-orange-300 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" /> Marketing AI (Openers)
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{data.ai_opener && (
|
||||
<div className="p-3 bg-white dark:bg-slate-900 rounded border border-orange-200 dark:border-orange-800">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<div className="text-[10px] text-orange-600 dark:text-orange-400 uppercase font-bold tracking-tight">Primary: Infrastructure/Cleaning</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-200 leading-relaxed italic">"{data.ai_opener}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.ai_opener_secondary && (
|
||||
<div className="p-3 bg-white dark:bg-slate-900 rounded border border-orange-200 dark:border-orange-800">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<div className="text-[10px] text-orange-600 dark:text-orange-400 uppercase font-bold tracking-tight">Secondary: Service/Logistics</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-200 leading-relaxed italic">"{data.ai_opener_secondary}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-slate-500 text-center">
|
||||
These sentences are statically pre-calculated for the "First Sentence Matching" strategy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CRM Comparison and Data Quality Renderer
|
||||
const renderDataQualityCard = () => {
|
||||
if (!data) return null;
|
||||
@@ -754,6 +795,7 @@ export function Inspector({ companyId, initialContactId, onClose, apiBase }: Ins
|
||||
|
||||
{renderDataQualityCard()}
|
||||
{renderStrategyCard()}
|
||||
{renderMarketingCard()}
|
||||
|
||||
<div className="bg-slate-50 dark:bg-slate-950 rounded-lg p-4 border border-slate-200 dark:border-slate-800 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Search, Edit2, X, Check, Filter, Download } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface MarketingMatrixManagerProps {
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
interface MatrixEntry {
|
||||
id: number
|
||||
industry_id: number
|
||||
persona_id: number
|
||||
industry_name: string
|
||||
persona_name: string
|
||||
subject: string | null
|
||||
intro: string | null
|
||||
social_proof: string | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Industry {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Persona {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function MarketingMatrixManager({ apiBase }: MarketingMatrixManagerProps) {
|
||||
const [entries, setEntries] = useState<MatrixEntry[]>([])
|
||||
const [industries, setIndustries] = useState<Industry[]>([])
|
||||
const [personas, setPersonas] = useState<Persona[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Filters
|
||||
const [industryFilter, setIndustryFilter] = useState<number | 'all'>('all')
|
||||
const [personaFilter, setPersonaFilter] = useState<number | 'all'>('all')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// Editing state
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editValues, setEditValues] = useState<{
|
||||
subject: string
|
||||
intro: string
|
||||
social_proof: string
|
||||
}>({ subject: '', intro: '', social_proof: '' })
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
try {
|
||||
const [resInd, resPers] = await Promise.all([
|
||||
axios.get(`${apiBase}/industries`),
|
||||
axios.get(`${apiBase}/matrix/personas`)
|
||||
])
|
||||
setIndustries(resInd.data)
|
||||
setPersonas(resPers.data)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch metadata:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchEntries = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params: any = {}
|
||||
if (industryFilter !== 'all') params.industry_id = industryFilter
|
||||
if (personaFilter !== 'all') params.persona_id = personaFilter
|
||||
|
||||
const res = await axios.get(`${apiBase}/matrix`, { params })
|
||||
setEntries(res.data)
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch matrix entries:", e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetadata()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries()
|
||||
}, [industryFilter, personaFilter])
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!searchTerm) return entries
|
||||
const s = searchTerm.toLowerCase()
|
||||
return entries.filter(e =>
|
||||
e.industry_name.toLowerCase().includes(s) ||
|
||||
e.persona_name.toLowerCase().includes(s) ||
|
||||
(e.subject?.toLowerCase().includes(s)) ||
|
||||
(e.intro?.toLowerCase().includes(s)) ||
|
||||
(e.social_proof?.toLowerCase().includes(s))
|
||||
)
|
||||
}, [entries, searchTerm])
|
||||
|
||||
const startEditing = (entry: MatrixEntry) => {
|
||||
setEditingId(entry.id)
|
||||
setEditValues({
|
||||
subject: entry.subject || '',
|
||||
intro: entry.intro || '',
|
||||
social_proof: entry.social_proof || ''
|
||||
})
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const saveEditing = async (id: number) => {
|
||||
try {
|
||||
await axios.put(`${apiBase}/matrix/${id}`, editValues)
|
||||
setEntries(prev => prev.map(e => e.id === id ? { ...e, ...editValues } : e))
|
||||
setEditingId(null)
|
||||
} catch (e) {
|
||||
alert("Save failed")
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadCSV = () => {
|
||||
let url = `${apiBase}/matrix/export`
|
||||
const params = new URLSearchParams()
|
||||
if (industryFilter !== 'all') params.append('industry_id', industryFilter.toString())
|
||||
if (personaFilter !== 'all') params.append('persona_id', personaFilter.toString())
|
||||
|
||||
if (params.toString()) {
|
||||
url += `?${params.toString()}`
|
||||
}
|
||||
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-slate-50 dark:bg-slate-950 p-3 rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-slate-400" />
|
||||
<span className="text-xs font-bold text-slate-500 uppercase">Filters:</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-2 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={industryFilter}
|
||||
onChange={e => setIndustryFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||
>
|
||||
<option value="all">All Industries</option>
|
||||
{industries.map(i => <option key={i.id} value={i.id}>{i.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-2 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={personaFilter}
|
||||
onChange={e => setPersonaFilter(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||
>
|
||||
<option value="all">All Personas</option>
|
||||
{personas.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search in texts..."
|
||||
className="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded px-9 py-1.5 text-xs outline-none focus:ring-1 focus:ring-blue-500"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownloadCSV}
|
||||
className="flex items-center gap-2 bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 px-3 py-1.5 rounded text-xs font-bold transition-all border border-slate-300 dark:border-slate-700"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
EXPORT CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-slate-200 dark:border-slate-800 rounded-lg overflow-hidden bg-white dark:bg-slate-950">
|
||||
<table className="w-full text-left text-xs table-fixed">
|
||||
<thead className="bg-slate-50 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 text-slate-500 font-bold uppercase">
|
||||
<tr>
|
||||
<th className="p-3 w-40">Combination</th>
|
||||
<th className="p-3 w-1/4">Subject Line</th>
|
||||
<th className="p-3 w-1/3">Intro Text</th>
|
||||
<th className="p-3">Social Proof</th>
|
||||
<th className="p-3 w-20 text-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/50">
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={5} className="p-12 text-center text-slate-400 italic">Loading matrix entries...</td></tr>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<tr><td colSpan={5} className="p-12 text-center text-slate-400 italic">No entries found for the selected filters.</td></tr>
|
||||
) : filteredEntries.map(entry => (
|
||||
<tr key={entry.id} className={clsx("group transition-colors", editingId === entry.id ? "bg-blue-50/50 dark:bg-blue-900/10" : "hover:bg-slate-50/50 dark:hover:bg-slate-900/30")}>
|
||||
<td className="p-3 align-top">
|
||||
<div className="font-bold text-slate-900 dark:text-white leading-tight mb-1">{entry.industry_name}</div>
|
||||
<div className="text-[10px] text-blue-600 dark:text-blue-400 font-bold uppercase tracking-wider">{entry.persona_name}</div>
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
{editingId === entry.id ? (
|
||||
<input
|
||||
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none"
|
||||
value={editValues.subject}
|
||||
onChange={e => setEditValues(v => ({ ...v, subject: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-slate-700 dark:text-slate-300">{entry.subject || <span className="text-slate-400 italic">Empty</span>}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
{editingId === entry.id ? (
|
||||
<textarea
|
||||
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none h-24 text-[11px]"
|
||||
value={editValues.intro}
|
||||
onChange={e => setEditValues(v => ({ ...v, intro: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-slate-600 dark:text-slate-400 line-clamp-4 hover:line-clamp-none transition-all">{entry.intro || <span className="text-slate-400 italic">Empty</span>}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top">
|
||||
{editingId === entry.id ? (
|
||||
<textarea
|
||||
className="w-full bg-white dark:bg-slate-900 border border-blue-300 dark:border-blue-700 rounded p-1.5 outline-none h-24 text-[11px]"
|
||||
value={editValues.social_proof}
|
||||
onChange={e => setEditValues(v => ({ ...v, social_proof: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-slate-600 dark:text-slate-400 line-clamp-4 hover:line-clamp-none transition-all">{entry.social_proof || <span className="text-slate-400 italic">Empty</span>}</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="p-3 align-top text-center">
|
||||
{editingId === entry.id ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => saveEditing(entry.id)}
|
||||
className="p-1.5 bg-green-600 text-white rounded hover:bg-green-500 transition-colors shadow-sm"
|
||||
title="Save Changes"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditing}
|
||||
className="p-1.5 bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-400 rounded hover:bg-slate-300 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startEditing(entry)}
|
||||
className="p-2 text-slate-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-full transition-all opacity-0 group-hover:opacity-100"
|
||||
title="Edit Entry"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import { X, Bot, Tag, Target, Users, Plus, Trash2, Save, Flag, Check, Ban, ExternalLink } from 'lucide-react'
|
||||
import { X, Bot, Tag, Target, Users, Plus, Trash2, Save, Flag, Check, Ban, ExternalLink, ChevronDown, Grid, Database, Download, UploadCloud, Play, AlertTriangle, Lightbulb, Sparkles } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { MarketingMatrixManager } from './MarketingMatrixManager'
|
||||
|
||||
interface RoboticsSettingsProps {
|
||||
isOpen: boolean
|
||||
@@ -9,6 +10,28 @@ interface RoboticsSettingsProps {
|
||||
apiBase: string
|
||||
}
|
||||
|
||||
type JobRolePatternType = {
|
||||
id: number;
|
||||
pattern_type: 'exact' | 'regex';
|
||||
pattern_value: string;
|
||||
role: string; // Should match Persona.name
|
||||
priority: number;
|
||||
is_active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type RawJobTitleType = {
|
||||
id: number;
|
||||
title: string;
|
||||
count: number;
|
||||
source: string;
|
||||
is_mapped: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type ReportedMistake = {
|
||||
id: number;
|
||||
company_id: number;
|
||||
@@ -24,31 +47,91 @@ type ReportedMistake = {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
type SuggestionItem = { word: string, count: number };
|
||||
type RoleSuggestions = Record<string, SuggestionItem[]>;
|
||||
|
||||
type OptimizationProposal = {
|
||||
target_role: string;
|
||||
regex: string;
|
||||
explanation: string;
|
||||
priority: number;
|
||||
covered_pattern_ids: number[];
|
||||
covered_titles: string[];
|
||||
false_positives: string[];
|
||||
}
|
||||
|
||||
export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsProps) {
|
||||
const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles' | 'mistakes'>(
|
||||
localStorage.getItem('roboticsSettingsActiveTab') as 'robotics' | 'industries' | 'roles' | 'mistakes' || 'robotics'
|
||||
const [activeTab, setActiveTab] = useState<'robotics' | 'industries' | 'roles' | 'matrix' | 'mistakes' | 'database'>(
|
||||
localStorage.getItem('roboticsSettingsActiveTab') as 'robotics' | 'industries' | 'roles' | 'matrix' | 'mistakes' | 'database' || 'robotics'
|
||||
)
|
||||
|
||||
const [roboticsCategories, setRoboticsCategories] = useState<any[]>([])
|
||||
const [industries, setIndustries] = useState<any[]>([])
|
||||
const [jobRoles, setJobRoles] = useState<any[]>([])
|
||||
const [jobRoles, setJobRoles] = useState<JobRolePatternType[]>([])
|
||||
const [rawJobTitles, setRawJobTitles] = useState<RawJobTitleType[]>([])
|
||||
const [reportedMistakes, setReportedMistakes] = useState<ReportedMistake[]>([])
|
||||
const [suggestions, setSuggestions] = useState<RoleSuggestions>({});
|
||||
const [optimizationProposals, setOptimizationProposals] = useState<OptimizationProposal[]>([]);
|
||||
const [showOptimizationModal, setShowOptimizationModal] = useState(false);
|
||||
|
||||
const [currentMistakeStatusFilter, setCurrentMistakeStatusFilter] = useState<string>("PENDING");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClassifying, setIsClassifying] = useState(false);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [roleSearch, setRoleSearch] = useState("");
|
||||
|
||||
// Database & Regex State
|
||||
const [fileToUpload, setFileToUpload] = useState<File | null>(null);
|
||||
const [uploadStatus, setUploadStatus] = useState<string>("");
|
||||
const [testPattern, setTestPattern] = useState("");
|
||||
const [testPatternType, setTestPatternType] = useState("regex");
|
||||
const [testString, setTestString] = useState("");
|
||||
const [testResult, setTestResult] = useState<boolean | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
const groupedAndFilteredRoles = useMemo(() => {
|
||||
const grouped = jobRoles.reduce((acc: Record<string, JobRolePatternType[]>, role) => {
|
||||
const key = role.role || 'Unassigned';
|
||||
if (!acc[key]) {
|
||||
acc[key] = [];
|
||||
}
|
||||
acc[key].push(role);
|
||||
return acc;
|
||||
}, {} as Record<string, JobRolePatternType[]>);
|
||||
|
||||
if (!roleSearch) {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
const filtered = {} as Record<string, JobRolePatternType[]>;
|
||||
for (const roleName in grouped) {
|
||||
const roles = grouped[roleName].filter((r: JobRolePatternType) =>
|
||||
r.pattern_value.toLowerCase().includes(roleSearch.toLowerCase())
|
||||
);
|
||||
if (roles.length > 0) {
|
||||
filtered[roleName] = roles;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}, [jobRoles, roleSearch]);
|
||||
|
||||
const fetchAllData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [resRobotics, resIndustries, resJobRoles, resMistakes] = await Promise.all([
|
||||
const [resRobotics, resIndustries, resJobRoles, resRawTitles, resMistakes, resSuggestions] = await Promise.all([
|
||||
axios.get(`${apiBase}/robotics/categories`),
|
||||
axios.get(`${apiBase}/industries`),
|
||||
axios.get(`${apiBase}/job_roles`),
|
||||
axios.get(`${apiBase}/job_roles/raw?unmapped_only=true`), // Ensure we only get unmapped
|
||||
axios.get(`${apiBase}/mistakes?status=${currentMistakeStatusFilter}`),
|
||||
axios.get(`${apiBase}/job_roles/suggestions`),
|
||||
]);
|
||||
setRoboticsCategories(resRobotics.data);
|
||||
setIndustries(resIndustries.data);
|
||||
setJobRoles(resJobRoles.data);
|
||||
setRawJobTitles(resRawTitles.data);
|
||||
setReportedMistakes(resMistakes.data.items);
|
||||
setSuggestions(resSuggestions.data);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch settings data:", e);
|
||||
alert("Fehler beim Laden der Settings. Siehe Konsole.");
|
||||
@@ -63,6 +146,21 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Refetch mistakes when filter changes
|
||||
const fetchMistakes = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`${apiBase}/mistakes?status=${currentMistakeStatusFilter}`);
|
||||
setReportedMistakes(res.data.items);
|
||||
} catch (e) { console.error(e) }
|
||||
finally { setIsLoading(false) }
|
||||
}
|
||||
fetchMistakes();
|
||||
}
|
||||
}, [currentMistakeStatusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('roboticsSettingsActiveTab', activeTab);
|
||||
}, [activeTab]);
|
||||
@@ -94,10 +192,120 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddJobRole = async () => {
|
||||
const handleBatchClassify = async () => {
|
||||
if (!window.confirm(`This will send all ${rawJobTitles.length} unmapped job titles to the AI for classification. This may take a few minutes. Continue?`)) {
|
||||
return;
|
||||
}
|
||||
setIsClassifying(true);
|
||||
try {
|
||||
await axios.post(`${apiBase}/job_roles/classify-batch`);
|
||||
alert("Batch classification started in the background. The list will update automatically as titles are processed. You can close this window.");
|
||||
} catch (e) {
|
||||
alert("Failed to start batch classification.");
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsClassifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimizePatterns = async () => {
|
||||
setIsOptimizing(true);
|
||||
setOptimizationProposals([]);
|
||||
setShowOptimizationModal(true);
|
||||
|
||||
try {
|
||||
// 1. Start Task
|
||||
await axios.post(`${apiBase}/job_roles/optimize-start`);
|
||||
|
||||
// 2. Poll for Status
|
||||
const pollInterval = 2000; // 2 seconds
|
||||
const maxAttempts = 150; // 5 minutes
|
||||
let attempts = 0;
|
||||
|
||||
const checkStatus = async () => {
|
||||
if (attempts >= maxAttempts) {
|
||||
alert("Optimization timed out. Please check logs.");
|
||||
setIsOptimizing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(`${apiBase}/job_roles/optimize-status`);
|
||||
const status = res.data.state;
|
||||
|
||||
if (status === 'completed') {
|
||||
setOptimizationProposals(res.data.result);
|
||||
setIsOptimizing(false);
|
||||
} else if (status === 'error') {
|
||||
alert(`Optimization failed: ${res.data.error}`);
|
||||
setIsOptimizing(false);
|
||||
} else {
|
||||
attempts++;
|
||||
setTimeout(checkStatus, pollInterval);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Polling error", e);
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(checkStatus, 1000);
|
||||
|
||||
} catch (e) {
|
||||
alert("Failed to start optimization.");
|
||||
console.error(e);
|
||||
setShowOptimizationModal(false);
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyOptimization = async (proposal: OptimizationProposal) => {
|
||||
try {
|
||||
await axios.post(`${apiBase}/job_roles/apply-optimization`, {
|
||||
target_role: proposal.target_role,
|
||||
regex: proposal.regex,
|
||||
priority: proposal.priority,
|
||||
ids_to_delete: proposal.covered_pattern_ids
|
||||
});
|
||||
// Remove applied proposal from list
|
||||
setOptimizationProposals(prev => prev.filter(p => p.regex !== proposal.regex));
|
||||
fetchAllData(); // Refresh main list
|
||||
} catch (e) {
|
||||
alert("Failed to apply optimization.");
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateJobRole = async (roleId: number, field: string, value: any) => {
|
||||
const roleToUpdate = jobRoles.find(r => r.id === roleId);
|
||||
if (!roleToUpdate) return;
|
||||
|
||||
const updatedRole = { ...roleToUpdate, [field]: value };
|
||||
|
||||
// Convert priority to number just in case
|
||||
if (field === 'priority') {
|
||||
updatedRole.priority = parseInt(value, 10);
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.put(`${apiBase}/job_roles/${roleId}`, updatedRole);
|
||||
// Optimistic update
|
||||
setJobRoles(jobRoles.map(r => r.id === roleId ? updatedRole : r));
|
||||
} catch (e) {
|
||||
alert("Failed to update job role");
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddJobRole = async (value: string, type: 'exact' | 'regex' = 'exact', roleName?: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await axios.post(`${apiBase}/job_roles`, { pattern: "New Pattern", role: "Operativer Entscheider" });
|
||||
await axios.post(`${apiBase}/job_roles`, {
|
||||
pattern_type: type,
|
||||
pattern_value: value,
|
||||
role: roleName || "Influencer",
|
||||
priority: type === 'regex' ? 80 : 100
|
||||
});
|
||||
fetchAllData();
|
||||
} catch (e) {
|
||||
alert("Failed to add job role");
|
||||
@@ -106,7 +314,11 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteJobRole = async (id: number) => {
|
||||
if (!window.confirm("Are you sure you want to delete this pattern?")) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await axios.delete(`${apiBase}/job_roles/${id}`);
|
||||
@@ -119,11 +331,66 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadDb = () => {
|
||||
window.location.href = `${apiBase}/admin/database/download`;
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
setFileToUpload(event.target.files[0]);
|
||||
setUploadStatus("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadDb = async () => {
|
||||
if (!fileToUpload) return;
|
||||
if (!window.confirm("WARNING: This will overwrite the current database! A backup will be created, but any recent changes might be lost. You MUST restart the container afterwards. Continue?")) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileToUpload);
|
||||
|
||||
setUploadStatus("uploading");
|
||||
|
||||
try {
|
||||
await axios.post(`${apiBase}/admin/database/upload`, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
setUploadStatus("success");
|
||||
alert("Upload successful! Please RESTART the Docker container to apply changes.");
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setUploadStatus("error");
|
||||
alert(`Upload failed: ${e.response?.data?.detail || e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestPattern = async () => {
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
if (!testPattern || !testString) return;
|
||||
|
||||
try {
|
||||
const res = await axios.post(`${apiBase}/job_roles/test-pattern`, {
|
||||
pattern: testPattern,
|
||||
pattern_type: testPatternType,
|
||||
test_string: testString
|
||||
});
|
||||
if (res.data.error) {
|
||||
setTestError(res.data.error);
|
||||
} else {
|
||||
setTestResult(res.data.match);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setTestError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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 relative">
|
||||
{/* Header */}
|
||||
<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>
|
||||
@@ -141,7 +408,9 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
{ id: 'robotics', label: 'Robotics Potential', icon: Bot },
|
||||
{ id: 'industries', label: 'Industry Focus', icon: Target },
|
||||
{ id: 'roles', label: 'Job Role Mapping', icon: Users },
|
||||
{ id: 'matrix', label: 'Marketing Matrix', icon: Grid },
|
||||
{ id: 'mistakes', label: 'Reported Mistakes', icon: Flag },
|
||||
{ id: 'database', label: 'Database & Regex', icon: Database },
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
@@ -251,26 +520,138 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="roles-content" className={clsx("space-y-4", { 'hidden': isLoading || activeTab !== 'roles' })}>
|
||||
<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} /></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 key="roles-content" className={clsx("space-y-8", { 'hidden': isLoading || activeTab !== 'roles' })}>
|
||||
{/* Existing Patterns Grouped */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search patterns..."
|
||||
value={roleSearch}
|
||||
onChange={e => setRoleSearch(e.target.value)}
|
||||
className="w-full bg-white dark:bg-slate-950 border border-slate-300 dark:border-slate-700 rounded-md px-3 py-1.5 text-xs text-slate-900 dark:text-white focus:ring-1 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleOptimizePatterns} className="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold rounded shadow-lg shadow-indigo-500/20 mr-2">
|
||||
<Sparkles className="h-3 w-3" /> OPTIMIZE
|
||||
</button>
|
||||
<button onClick={() => handleAddJobRole("New Pattern", "exact", "Influencer")} 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 shadow-lg shadow-blue-500/20"><Plus className="h-3 w-3" /> ADD PATTERN</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{Object.keys(groupedAndFilteredRoles).sort().map(roleName => (
|
||||
<details key={roleName} className="bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-lg group" open={!!roleSearch}>
|
||||
<summary className="p-3 cursor-pointer flex justify-between items-center group-hover:bg-slate-50 dark:group-hover:bg-slate-900 transition-colors">
|
||||
<div className="font-semibold text-slate-800 dark:text-slate-200 text-xs">
|
||||
{roleName}
|
||||
<span className="ml-2 text-slate-400 font-normal">({groupedAndFilteredRoles[roleName].length} patterns)</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 text-slate-400 transform group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-800">
|
||||
{/* AI Suggestions Area */}
|
||||
{suggestions[roleName] && suggestions[roleName].length > 0 && (
|
||||
<div className="p-3 bg-blue-50/50 dark:bg-blue-900/10 border-b border-slate-100 dark:border-slate-800/50">
|
||||
<div className="flex items-center gap-2 mb-2 text-[10px] uppercase font-bold text-blue-600 dark:text-blue-400">
|
||||
<Lightbulb className="h-3 w-3" />
|
||||
AI Suggestions (Common Keywords)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions[roleName].map(s => (
|
||||
<button
|
||||
key={s.word}
|
||||
onClick={() => {
|
||||
const regex = `(?i).*${s.word}.*`; // Simple starting point
|
||||
if(window.confirm(`Create Regex pattern "${regex}" for role "${roleName}"?`)) {
|
||||
handleAddJobRole(regex, 'regex', roleName);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2 py-1 bg-white dark:bg-slate-900 border border-blue-200 dark:border-blue-900 rounded-full text-[10px] text-slate-700 dark:text-slate-300 hover:border-blue-400 hover:text-blue-600 transition-colors"
|
||||
title={`Found ${s.count} times`}
|
||||
>
|
||||
{s.word} <span className="opacity-50 text-[9px]">({s.count})</span>
|
||||
<Plus className="h-2 w-2 text-blue-400" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-50 dark:bg-slate-900/50 text-slate-500 font-bold uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="p-2">Type</th>
|
||||
<th className="p-2">Pattern Value</th>
|
||||
<th className="p-2">Priority</th>
|
||||
<th className="p-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/50">
|
||||
{groupedAndFilteredRoles[roleName].map((role: JobRolePatternType) => (
|
||||
<tr key={role.id} className="group/row hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors">
|
||||
<td className="p-1.5">
|
||||
<select className="w-full bg-transparent border border-transparent hover:border-slate-300 dark:hover:border-slate-700 rounded px-1 py-0.5 text-slate-900 dark:text-slate-200 outline-none focus:border-blue-500" defaultValue={role.pattern_type} onChange={(e) => handleUpdateJobRole(role.id, 'pattern_type', e.target.value)}>
|
||||
<option>exact</option>
|
||||
<option>regex</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="p-1.5"><input className="w-full bg-transparent border border-transparent hover:border-slate-300 dark:hover:border-slate-700 rounded px-1 py-0.5 text-slate-900 dark:text-slate-200 outline-none focus:border-blue-500 font-mono" defaultValue={role.pattern_value} onBlur={(e) => handleUpdateJobRole(role.id, 'pattern_value', e.target.value)} /></td>
|
||||
<td className="p-1.5"><input type="number" className="w-16 bg-transparent border border-transparent hover:border-slate-300 dark:hover:border-slate-700 rounded px-1 py-0.5 text-slate-900 dark:text-slate-200 outline-none focus:border-blue-500 font-mono" defaultValue={role.priority} onBlur={(e) => handleUpdateJobRole(role.id, 'priority', e.target.value)} /></td>
|
||||
<td className="p-1.5 text-center"><button onClick={() => handleDeleteJobRole(role.id)} className="text-slate-400 hover:text-red-500 opacity-0 group-hover/row:opacity-100 transition-all transform hover:scale-110"><Trash2 className="h-4 w-4" /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discovery Inbox */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-200 dark:border-slate-800">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-300">Discovery Inbox</h3>
|
||||
<p className="text-[10px] text-slate-500 uppercase font-semibold">Unmapped job titles from CRM, prioritized by frequency</p>
|
||||
</div>
|
||||
{rawJobTitles.length > 0 && (
|
||||
<button
|
||||
onClick={handleBatchClassify}
|
||||
disabled={isClassifying}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-bold rounded shadow-lg shadow-green-500/20 disabled:bg-slate-400 disabled:shadow-none"
|
||||
>
|
||||
<Bot className="h-3 w-3" />
|
||||
{isClassifying ? 'CLASSIFYING...' : `CLASSIFY ${rawJobTitles.length} TITLES`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50/50 dark:bg-slate-900/20 border border-dashed border-slate-300 dark:border-slate-700 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-100/50 dark:bg-slate-900/80 border-b border-slate-200 dark:border-slate-800 text-slate-400 font-bold uppercase tracking-wider"><tr><th className="p-3">Job Title from CRM</th><th className="p-3 w-20 text-center">Frequency</th><th className="p-3 w-10"></th></tr></thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/50">
|
||||
{rawJobTitles.map((raw: RawJobTitleType) => (
|
||||
<tr key={raw.id} className="group hover:bg-white dark:hover:bg-slate-800 transition-colors">
|
||||
<td className="p-3 font-medium text-slate-600 dark:text-slate-400 italic">{raw.title}</td>
|
||||
<td className="p-3 text-center"><span className="px-2 py-1 bg-slate-200 dark:bg-slate-800 rounded-full font-bold text-[10px] text-slate-500">{raw.count}x</span></td>
|
||||
<td className="p-3 text-center"><button onClick={() => handleAddJobRole(raw.title, "exact", "Influencer")} className="p-1 text-blue-500 hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded transition-all"><Plus className="h-4 w-4" /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{rawJobTitles.length === 0 && (<tr><td colSpan={3} className="p-12 text-center text-slate-400 italic">Discovery inbox is empty. Import raw job titles to see data here.</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="matrix-content" className={clsx("space-y-4", { 'hidden': isLoading || activeTab !== 'matrix' })}>
|
||||
<MarketingMatrixManager apiBase={apiBase} />
|
||||
</div>
|
||||
|
||||
<div key="mistakes-content" className={clsx("space-y-4", { 'hidden': isLoading || activeTab !== 'mistakes' })}>
|
||||
{/* ... existing mistakes content ... */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-300">Reported Data Mistakes</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -349,7 +730,202 @@ export function RoboticsSettings({ isOpen, onClose, apiBase }: RoboticsSettingsP
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key="database-content" className={clsx("space-y-6", { 'hidden': isLoading || activeTab !== 'database' })}>
|
||||
|
||||
{/* Regex Tester */}
|
||||
<div className="bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-300 flex items-center gap-2"><Bot className="h-4 w-4" /> Regex Tester</h3>
|
||||
<p className="text-xs text-slate-500">Validate your patterns before adding them to the database.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">Pattern</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={testPatternType}
|
||||
onChange={e => setTestPatternType(e.target.value)}
|
||||
className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded text-xs px-2 focus:ring-1 focus:ring-blue-500 outline-none"
|
||||
>
|
||||
<option value="regex">Regex</option>
|
||||
<option value="exact">Exact</option>
|
||||
<option value="startswith">Starts With</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={testPattern}
|
||||
onChange={e => setTestPattern(e.target.value)}
|
||||
placeholder="e.g. (leiter|head).{0,15}vertrieb"
|
||||
className="flex-1 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded p-2 text-xs font-mono text-slate-800 dark:text-slate-200 focus:ring-1 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">Test String (Job Title)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={testString}
|
||||
onChange={e => setTestString(e.target.value)}
|
||||
placeholder="e.g. Leiter Vertrieb und Marketing"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleTestPattern} className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded shadow-lg shadow-blue-500/20">
|
||||
<Play className="h-3 w-3" /> TEST PATTERN
|
||||
</button>
|
||||
{testResult !== null && (
|
||||
<span className={clsx("px-3 py-1.5 rounded text-xs font-bold flex items-center gap-1", testResult ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700")}>
|
||||
{testResult ? <Check className="h-3 w-3" /> : <Ban className="h-3 w-3" />}
|
||||
{testResult ? "MATCH" : "NO MATCH"}
|
||||
</span>
|
||||
)}
|
||||
{testError && <span className="text-xs text-red-500 font-mono">{testError}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Management */}
|
||||
<div className="bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-700 dark:text-slate-300 flex items-center gap-2"><Database className="h-4 w-4" /> Database Management</h3>
|
||||
<p className="text-xs text-slate-500">Download the full database for offline analysis or restore a backup.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
|
||||
{/* Download */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-slate-600 dark:text-slate-400 uppercase">Export</h4>
|
||||
<button onClick={handleDownloadDb} className="w-full flex items-center justify-center gap-2 px-4 py-8 bg-white dark:bg-slate-900 border-2 border-dashed border-slate-300 dark:border-slate-700 hover:border-blue-500 hover:text-blue-500 dark:hover:border-blue-400 dark:hover:text-blue-400 rounded-xl text-slate-500 transition-all group">
|
||||
<Download className="h-6 w-6 group-hover:scale-110 transition-transform" />
|
||||
<span className="font-semibold text-sm">Download Database (SQLite)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-slate-600 dark:text-slate-400 uppercase">Restore / Import</h4>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-amber-600 bg-amber-50 dark:bg-amber-900/20 p-2 rounded text-[10px]">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>Warning: Uploading will overwrite the current database. A backup will be created automatically.</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
className="block w-full text-xs text-slate-500
|
||||
file:mr-4 file:py-2 file:px-4
|
||||
file:rounded-full file:border-0
|
||||
file:text-xs file:font-semibold
|
||||
file:bg-blue-50 file:text-blue-700
|
||||
hover:file:bg-blue-100
|
||||
"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleUploadDb}
|
||||
disabled={!fileToUpload || uploadStatus === 'uploading'}
|
||||
className={clsx(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2 text-white text-xs font-bold rounded transition-all",
|
||||
!fileToUpload ? "bg-slate-300 dark:bg-slate-800 cursor-not-allowed" : "bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20"
|
||||
)}
|
||||
>
|
||||
{uploadStatus === 'uploading' ? (
|
||||
<span className="animate-pulse">Uploading...</span>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="h-3 w-3" /> UPLOAD & REPLACE DB
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Optimization Modal */}
|
||||
{showOptimizationModal && (
|
||||
<div className="absolute inset-0 z-[70] bg-white dark:bg-slate-900 p-6 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<div className="flex justify-between items-center pb-4 border-b border-slate-200 dark:border-slate-800">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-white flex items-center gap-2"><Sparkles className="h-5 w-5 text-indigo-500" /> Pattern Optimization Proposals</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">AI-generated Regex suggestions to consolidate exact matches.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowOptimizationModal(false)} className="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-full"><X className="h-5 w-5 text-slate-500" /></button>
|
||||
</div>
|
||||
|
||||
{isOptimizing ? (
|
||||
<div className="py-20 text-center space-y-4">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-indigo-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
<p className="text-sm text-slate-500 font-medium">Analyzing patterns & checking for conflicts...</p>
|
||||
</div>
|
||||
) : optimizationProposals.length === 0 ? (
|
||||
<div className="py-20 text-center text-slate-500">
|
||||
<Check className="h-10 w-10 mx-auto text-green-500 mb-4" />
|
||||
<p>No optimization opportunities found. Your patterns are already efficient!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{optimizationProposals.map((prop, idx) => (
|
||||
<div key={idx} className="bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl p-5 shadow-sm">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded">{prop.target_role}</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">Priority: {prop.priority}</span>
|
||||
</div>
|
||||
<h3 className="font-mono text-sm font-bold text-slate-800 dark:text-slate-200 bg-white dark:bg-slate-900 px-2 py-1 rounded border border-slate-200 dark:border-slate-800 inline-block">{prop.regex}</h3>
|
||||
</div>
|
||||
<button onClick={() => handleApplyOptimization(prop)} className="bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold px-4 py-2 rounded shadow-lg shadow-indigo-500/20 transition-all flex items-center gap-2">
|
||||
APPLY & REPLACE {prop.covered_pattern_ids.length} PATTERNS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400 mb-4 italic">{prop.explanation}</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-green-50/50 dark:bg-green-900/10 border border-green-100 dark:border-green-900/30 rounded p-3">
|
||||
<div className="text-[10px] font-bold uppercase text-green-600 mb-2 flex items-center gap-1"><Check className="h-3 w-3" /> Covers ({prop.covered_titles.length})</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prop.covered_titles.map(t => (
|
||||
<span key={t} className="text-[9px] px-1.5 py-0.5 bg-white dark:bg-slate-900 rounded border border-green-200 dark:border-green-800 text-slate-600 dark:text-slate-300">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prop.false_positives.length > 0 ? (
|
||||
<div className="bg-red-50/50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/30 rounded p-3">
|
||||
<div className="text-[10px] font-bold uppercase text-red-600 mb-2 flex items-center gap-1"><AlertTriangle className="h-3 w-3" /> Conflicts (Matches other roles!)</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prop.false_positives.map(t => (
|
||||
<span key={t} className="text-[9px] px-1.5 py-0.5 bg-white dark:bg-slate-900 rounded border border-red-200 dark:border-red-800 text-slate-600 dark:text-slate-300">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-[10px] text-slate-400 italic bg-slate-100 dark:bg-slate-900 rounded border border-dashed border-slate-200 dark:border-slate-800">
|
||||
No conflicts with other roles detected.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user