import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ConfirmDialog } from '../ui/Modal'; import { useToast } from '../Toast'; import { MatchDetailModal } from './MatchDetailModal'; import { EmptyState, MatchBadge, MatchStatusBadge, Spinner, cardClass, formatDateTime, primaryButtonClass, secondaryButtonClass, projectTimestamp, AlreadyAppliedBadge, } from './scannerUi'; import { SCANNER_SETTINGS_DEFAULTS, scannerApi, type MatchStatus, type ScannerAgent, type ScannerMatch, type ScannerSettings, type ScannerThresholds, type ScannerStatus, } from '../../lib/scannerApi'; /* * Dashboard des Projekt Scanners. Verhalten wie in der eigenstaendigen App: * alle 30 Sekunden nachladen, bei neuen Matches oberhalb des * Benachrichtigungs-Schwellenwerts Signalton und Hinweisbanner. */ /** Kurzer Zweiklang ueber die Web-Audio-API - kein Audio-Asset notwendig. */ function playMatchBeep() { try { const AudioCtor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioCtor) return; const ctx = new AudioCtor(); const playTone = (freq: number, start: number, duration: number) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; gain.gain.setValueAtTime(0, ctx.currentTime + start); gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + start + 0.02); gain.gain.linearRampToValueAtTime(0, ctx.currentTime + start + duration); osc.connect(gain).connect(ctx.destination); osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + duration); }; playTone(880, 0, 0.18); playTone(1175, 0.18, 0.22); setTimeout(() => ctx.close().catch(() => {}), 600); } catch { // Kein Ton ist kein Grund, das Dashboard zu stoeren. } } type TimeFilter = '24h' | '2d' | '3d' | '7d' | 'all'; const TIME_WINDOWS: Record = { '24h': 24 * 60 * 60 * 1000, '2d': 2 * 24 * 60 * 60 * 1000, '3d': 3 * 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, all: null, }; const TIME_LABELS: Record = { '24h': 'letzte 24 Stunden', '2d': 'letzte 2 Tage', '3d': 'letzte 3 Tage', '7d': 'letzte Woche', all: 'alle', }; type SortKey = 'matchPercentage' | 'agent' | 'title' | 'portal' | 'status' | 'createdAt'; const COLUMNS: { key: SortKey; label: string }[] = [ { key: 'matchPercentage', label: 'Match' }, { key: 'agent', label: 'Berater (Agent)' }, { key: 'title', label: 'Projekt-Titel' }, { key: 'portal', label: 'Plattform' }, { key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Gefunden am' }, ]; const selectClass = 'rounded-lg border border-ink-200 bg-surface px-2.5 py-1.5 text-xs font-semibold text-ink-700 outline-none transition focus:border-brand-500'; const numberInputClass = 'w-16 rounded-lg border border-ink-200 bg-surface px-2 py-1.5 text-xs font-semibold text-ink-700 outline-none transition focus:border-brand-500'; interface Stats { activeAgents: number; portalsOnline: number; portalsTotal: number; projects: number; matches: number; } const StatCard: React.FC<{ icon: React.ReactNode; value: string; label: string }> = ({ icon, value, label, }) => (
{icon}

{value}

{label}

); const icon = (path: string) => ( ); export const ScannerDashboard: React.FC = () => { const { addToast } = useToast(); const [matches, setMatches] = useState([]); const [agents, setAgents] = useState([]); const [stats, setStats] = useState({ activeAgents: 0, portalsOnline: 0, portalsTotal: 0, projects: 0, matches: 0, }); const [isLoading, setIsLoading] = useState(true); const [isScanning, setIsScanning] = useState(false); const [status, setStatus] = useState(null); const [selectedMatch, setSelectedMatch] = useState(null); const [matchToDelete, setMatchToDelete] = useState(null); const [timeFilter, setTimeFilter] = useState('24h'); const [agentFilter, setAgentFilter] = useState<'all' | number>('all'); const [displayThreshold, setDisplayThreshold] = useState(0); const [matchThreshold, setMatchThreshold] = useState(70); const [saveState, setSaveState] = useState<'idle' | 'pending' | 'saved' | 'error'>('idle'); const saveTimer = useRef(null); const [sortKey, setSortKey] = useState('createdAt'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); // Benachrichtigungen laufen ueber Refs, damit das Polling keine Render-Schleife // ausloest. null = erster Durchlauf, der nur die Grundmenge merkt. const seenMatchIds = useRef | null>(null); const notifyThreshold = useRef(70); const soundEnabled = useRef(true); const [banner, setBanner] = useState<{ count: number; text: string } | null>(null); const load = useCallback( async (silent = false) => { if (!silent) setIsLoading(true); try { const [agentRows, portalRows, matchRows, settings, statusRow, mine] = await Promise.all([ scannerApi.agents(), scannerApi.portals(), scannerApi.matches({ limit: 2000 }), // Fehlende Einstellungen sind kein Grund, das Dashboard leer zu lassen. scannerApi.settings().catch((): Partial => ({})), scannerApi.status().catch((): ScannerStatus | null => null), scannerApi.myThresholds().catch((): ScannerThresholds | null => null), ]); setAgents(agentRows); setMatches(matchRows); setStatus(statusRow); // Ein Lauf aus dem Scheduler oder aus einem anderen Browserfenster soll // den Knopf hier genauso sperren wie ein selbst gestarteter. if (statusRow) setIsScanning(statusRow.scanRunning); // Eigene Schwellen des Benutzers gehen vor, sonst der globale Standard. const parsedDisplay = mine?.display_threshold ?? parseInt(settings.display_threshold ?? SCANNER_SETTINGS_DEFAULTS.display_threshold, 10); if (!Number.isNaN(parsedDisplay)) setDisplayThreshold(parsedDisplay); const parsedNotify = mine?.match_threshold ?? parseInt(settings.match_threshold ?? SCANNER_SETTINGS_DEFAULTS.match_threshold, 10); if (!Number.isNaN(parsedNotify)) { notifyThreshold.current = parsedNotify; setMatchThreshold(parsedNotify); } soundEnabled.current = settings.sound_notifications_active !== 'false'; // Abgelehnte Treffer sind ausgeblendet und zaehlen auch nicht mit. const countable = matchRows.filter((m) => m.status !== 'rejected'); setStats({ activeAgents: agentRows.filter((a) => a.isActive).length, portalsOnline: portalRows.filter((p) => p.connectionStatus === 'connected').length, portalsTotal: portalRows.length, projects: new Set(countable.map((m) => m.projectId)).size, matches: countable.length, }); // Neue Treffer oberhalb des Schwellenwerts melden. const currentIds = new Set(matchRows.map((m) => m.id)); if (seenMatchIds.current === null) { seenMatchIds.current = currentIds; } else { const fresh = matchRows.filter( (m) => !seenMatchIds.current?.has(m.id) && (m.matchPercentage ?? 0) >= notifyThreshold.current ); if (fresh.length > 0) { if (soundEnabled.current) playMatchBeep(); const titles = fresh .slice(0, 3) .map((m) => `${m.matchPercentage}% – ${m.Project?.title ?? ''}`); const more = fresh.length > 3 ? ` (+${fresh.length - 3} weitere)` : ''; setBanner({ count: fresh.length, text: titles.join(' · ') + more }); if ('Notification' in window && Notification.permission === 'granted') { try { new Notification(`${fresh.length} neuer Match`, { body: titles.join('\n'), tag: 'perinco-scanner-match', }); } catch { // Browser kann die Benachrichtigung ablehnen - nicht kritisch. } } } seenMatchIds.current = currentIds; } } catch (err) { if (!silent) { addToast( err instanceof Error ? err.message : 'Scanner-Daten konnten nicht geladen werden.', 'error' ); } } finally { setIsLoading(false); } }, [addToast] ); useEffect(() => { load(); const timer = window.setInterval(() => load(true), 30_000); return () => window.clearInterval(timer); }, [load]); // Browser fragen erst nach einer Interaktion nach der Erlaubnis. useEffect(() => { const ask = () => { if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission().catch(() => {}); } }; window.addEventListener('click', ask, { once: true }); window.addEventListener('keydown', ask, { once: true }); return () => { window.removeEventListener('click', ask); window.removeEventListener('keydown', ask); }; }, []); useEffect(() => { if (!banner) return; const timer = window.setTimeout(() => setBanner(null), 10_000); return () => window.clearTimeout(timer); }, [banner]); useEffect(() => () => { if (saveTimer.current) window.clearTimeout(saveTimer.current); }, []); /** Beide Schwellenwerte gehoeren dem Benutzer - gebuendelt speichern. */ const persistThresholds = (display: number, notify: number) => { if (saveTimer.current) window.clearTimeout(saveTimer.current); setSaveState('pending'); saveTimer.current = window.setTimeout(async () => { try { await scannerApi.saveMyThresholds({ display_threshold: display, match_threshold: notify, }); setSaveState('saved'); window.setTimeout(() => setSaveState('idle'), 1500); } catch { setSaveState('error'); } }, 500); }; const clamp = (value: string) => { const parsed = parseInt(value, 10); return Number.isNaN(parsed) ? 0 : Math.max(0, Math.min(100, parsed)); }; const handleScan = async () => { if (isScanning) return; setIsScanning(true); try { const { message } = await scannerApi.scan(); addToast(message, 'success'); // Der Scan laeuft im Hintergrund; erste Treffer koennen nach wenigen // Sekunden da sein. Das Ende meldet der Statusabruf unten. window.setTimeout(() => load(true), 8000); } catch (err) { addToast(err instanceof Error ? err.message : 'Scan konnte nicht gestartet werden.', 'error'); setIsScanning(false); } }; // Waehrend ein Lauf aktiv ist, haeufiger nachsehen als im 30-Sekunden-Takt - // sonst bleibt der Knopf nach dem Ende unnoetig lange gesperrt. useEffect(() => { if (!isScanning) return; const timer = window.setInterval(async () => { try { const current = await scannerApi.status(); setStatus(current); if (!current.scanRunning) { setIsScanning(false); load(true); } } catch { // Netzwerkaussetzer aendern den Zustand nicht. } }, 5000); return () => window.clearInterval(timer); }, [isScanning, load]); const changeStatus = async (match: ScannerMatch, status: MatchStatus) => { try { await scannerApi.setMatchStatus(match.id, status); setMatches((prev) => prev.map((m) => (m.id === match.id ? { ...m, status } : m))); setSelectedMatch((prev) => (prev && prev.id === match.id ? { ...prev, status } : prev)); if (status === 'rejected') { setSelectedMatch(null); addToast('Treffer nach „Unpassend" verschoben.', 'success'); load(true); } } catch (err) { addToast(err instanceof Error ? err.message : 'Status konnte nicht geändert werden.', 'error'); } }; const confirmDelete = async () => { if (!matchToDelete) return; try { await scannerApi.removeMatch(matchToDelete.id); setMatches((prev) => prev.filter((m) => m.id !== matchToDelete.id)); setSelectedMatch(null); addToast('Treffer gelöscht.', 'success'); load(true); } catch (err) { addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error'); } finally { setMatchToDelete(null); } }; const toggleSort = (key: SortKey) => { if (sortKey === key) { setSortDirection((direction) => (direction === 'asc' ? 'desc' : 'asc')); } else { setSortKey(key); setSortDirection('desc'); } }; const visibleMatches = useMemo(() => { const window_ = TIME_WINDOWS[timeFilter]; const cutoff = window_ ? Date.now() - window_ : null; const sortValue = (match: ScannerMatch): string | number => { switch (sortKey) { case 'matchPercentage': return match.matchPercentage ?? 0; case 'agent': return (match.Agent?.name ?? '').toLowerCase(); case 'title': return (match.Project?.title ?? '').toLowerCase(); case 'portal': return (match.Project?.portal ?? '').toLowerCase(); case 'status': return match.status ?? ''; case 'createdAt': return new Date(match.createdAt).getTime(); } }; return matches .filter((match) => match.status !== 'rejected') .filter((match) => (match.matchPercentage ?? 0) >= displayThreshold) .filter((match) => agentFilter === 'all' || match.agentId === agentFilter) // Gefiltert wird nach dem Alter des Projekts, nicht des Matches. .filter((match) => (cutoff === null ? true : projectTimestamp(match) >= cutoff)) .slice() .sort((a, b) => { const left = sortValue(a); const right = sortValue(b); if (left < right) return sortDirection === 'asc' ? -1 : 1; if (left > right) return sortDirection === 'asc' ? 1 : -1; return 0; }); }, [matches, timeFilter, agentFilter, displayThreshold, sortKey, sortDirection]); return (

Übersicht über Suchagenten, Portale und Projektübereinstimmungen.

{status && (
{/* Beim Parallelbetrieb ist wichtig, ob dieser Scanner selbst sucht - die alte App hat ihren eigenen Zeitplan. */} {!status.scanningActive ? ( ○ Scanner pausiert ) : status.schedulerEnabled ? ( ● Automatische Suche aktiv ) : ( ○ Nur manuelle Suche )} {status.scanRunning && ( Suchlauf läuft )} {status.database}
)}
{banner && (

🔔 {banner.count} neuer Match

{banner.text}

)}

Projekt-Übereinstimmungen

{visibleMatches.length} / {matches.length} {saveState === 'pending' && ' · speichert…'} {saveState === 'saved' && ' · gespeichert'} {saveState === 'error' && ' · Fehler'}
{isLoading ? ( ) : matches.length === 0 ? ( Noch keine Matches. Lege Suchagenten mit CV und Keywords an und starte einen Scan. ) : visibleMatches.length === 0 ? ( Keine Treffer für die aktuellen Filter (Zeitraum, Agent, Anzeige ab {displayThreshold}%). ) : (
{COLUMNS.map((column) => ( ))} {visibleMatches.map((match) => ( setSelectedMatch(match)} className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/50" > ))}
toggleSort(column.key)} className="cursor-pointer select-none px-3 py-2.5 text-left text-[10px] font-extrabold uppercase tracking-wider text-ink-400 transition hover:text-brand-700" > {column.label} {sortKey === column.key && (sortDirection === 'asc' ? ' ▲' : ' ▼')} Aktionen
{match.Agent?.name ?? '—'}

{match.Project?.title}

{match.Project?.alreadyApplied && }

{match.Project?.location || 'Remote'}

{match.Project?.portal} {formatDateTime(match.createdAt)} event.stopPropagation()}>
)}
{selectedMatch && ( setSelectedMatch(null)} onStatusChange={(status) => changeStatus(selectedMatch, status)} onDelete={() => setMatchToDelete(selectedMatch)} onProjectChange={(project) => setMatches((prev) => prev.map((m) => (m.projectId === project.id ? { ...m, Project: project } : m)) ) } /> )} {matchToDelete && ( setMatchToDelete(null)} /> )}
); };