[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:
2026-03-07 14:08:42 +00:00
parent efcaa57cf0
commit ae2303b733
404 changed files with 24100 additions and 13301 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Company Explorer (Robotics)</title>
<script type="module" crossorigin src="/ce/assets/index-tQU9lyIc.js"></script>
<link rel="stylesheet" crossorigin href="/ce/assets/index-BgxQoHsm.css">
</head>
<body class="bg-slate-950 text-slate-100">
<div id="root"></div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,9 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>
)
}

View File

@@ -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>
)

View File

@@ -4,7 +4,6 @@ export default {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},