"Anzeige ab %" und "Hinweis ab %" liegen jetzt am CRM-Benutzer (crm_users.scanner_display_threshold/scanner_match_threshold) statt global in den Scanner-Einstellungen. NULL = globaler Standard. Die Mail-Schwelle bleibt global, die KI-Suche nimmt die Anzeigeschwelle des Suchenden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
690 lines
26 KiB
TypeScript
690 lines
26 KiB
TypeScript
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<TimeFilter, number | null> = {
|
||
'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<TimeFilter, string> = {
|
||
'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,
|
||
}) => (
|
||
<div className={`${cardClass} flex items-center gap-4`}>
|
||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||
{icon}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-2xl font-extrabold text-ink-900">{value}</p>
|
||
<p className="truncate text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||
{label}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const icon = (path: string) => (
|
||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d={path} />
|
||
</svg>
|
||
);
|
||
|
||
export const ScannerDashboard: React.FC = () => {
|
||
const { addToast } = useToast();
|
||
|
||
const [matches, setMatches] = useState<ScannerMatch[]>([]);
|
||
const [agents, setAgents] = useState<ScannerAgent[]>([]);
|
||
const [stats, setStats] = useState<Stats>({
|
||
activeAgents: 0,
|
||
portalsOnline: 0,
|
||
portalsTotal: 0,
|
||
projects: 0,
|
||
matches: 0,
|
||
});
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isScanning, setIsScanning] = useState(false);
|
||
const [status, setStatus] = useState<ScannerStatus | null>(null);
|
||
|
||
const [selectedMatch, setSelectedMatch] = useState<ScannerMatch | null>(null);
|
||
const [matchToDelete, setMatchToDelete] = useState<ScannerMatch | null>(null);
|
||
|
||
const [timeFilter, setTimeFilter] = useState<TimeFilter>('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<number | null>(null);
|
||
|
||
const [sortKey, setSortKey] = useState<SortKey>('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<Set<number> | 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<ScannerSettings> => ({})),
|
||
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 (
|
||
<div className="space-y-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="text-sm text-ink-500">
|
||
Übersicht über Suchagenten, Portale und Projektübereinstimmungen.
|
||
</p>
|
||
{status && (
|
||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] font-extrabold uppercase tracking-wider">
|
||
{/* Beim Parallelbetrieb ist wichtig, ob dieser Scanner selbst
|
||
sucht - die alte App hat ihren eigenen Zeitplan. */}
|
||
{!status.scanningActive ? (
|
||
<span className="rounded-md border border-ink-200 bg-ink-100 px-2 py-0.5 text-ink-500">
|
||
○ Scanner pausiert
|
||
</span>
|
||
) : status.schedulerEnabled ? (
|
||
<span className="rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-brand-700">
|
||
● Automatische Suche aktiv
|
||
</span>
|
||
) : (
|
||
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-700">
|
||
○ Nur manuelle Suche
|
||
</span>
|
||
)}
|
||
{status.scanRunning && (
|
||
<span className="rounded-md border border-sky-200 bg-sky-50 px-2 py-0.5 text-sky-700">
|
||
Suchlauf läuft
|
||
</span>
|
||
)}
|
||
<span className="normal-case tracking-normal text-ink-400">{status.database}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button onClick={handleScan} disabled={isScanning} className={primaryButtonClass}>
|
||
{isScanning ? (
|
||
<>
|
||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
||
Scanne...
|
||
</>
|
||
) : (
|
||
<>
|
||
{icon('M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15')}
|
||
Jetzt scannen
|
||
</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{banner && (
|
||
<div className="flex items-center justify-between gap-4 rounded-2xl border border-brand-200 bg-brand-50 px-5 py-4">
|
||
<div className="min-w-0">
|
||
<p className="text-sm font-extrabold text-brand-700">
|
||
🔔 {banner.count} neuer Match
|
||
</p>
|
||
<p className="truncate text-xs text-ink-600">{banner.text}</p>
|
||
</div>
|
||
<button onClick={() => setBanner(null)} className={secondaryButtonClass}>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
<StatCard
|
||
icon={icon(
|
||
'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z'
|
||
)}
|
||
value={String(stats.activeAgents)}
|
||
label="Aktive Agenten"
|
||
/>
|
||
<StatCard
|
||
icon={icon(
|
||
'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'
|
||
)}
|
||
value={`${stats.portalsOnline} / ${stats.portalsTotal}`}
|
||
label="Portale online"
|
||
/>
|
||
<StatCard
|
||
icon={icon('M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z')}
|
||
value={String(stats.projects)}
|
||
label="Scraped Projekte"
|
||
/>
|
||
<StatCard
|
||
icon={icon(
|
||
'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z'
|
||
)}
|
||
value={String(stats.matches)}
|
||
label="Gesamt Matches"
|
||
/>
|
||
</div>
|
||
|
||
<div className={cardClass}>
|
||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||
<h3 className="text-base text-ink-900">Projekt-Übereinstimmungen</h3>
|
||
|
||
<div className="flex flex-wrap items-center gap-3 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||
<label className="flex items-center gap-1.5">
|
||
Zeitraum
|
||
<select
|
||
value={timeFilter}
|
||
onChange={(event) => setTimeFilter(event.target.value as TimeFilter)}
|
||
className={selectClass}
|
||
>
|
||
{Object.entries(TIME_LABELS).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-1.5">
|
||
Agent
|
||
<select
|
||
value={String(agentFilter)}
|
||
onChange={(event) =>
|
||
setAgentFilter(event.target.value === 'all' ? 'all' : Number(event.target.value))
|
||
}
|
||
className={selectClass}
|
||
>
|
||
<option value="all">alle</option>
|
||
{agents.map((agent) => (
|
||
<option key={agent.id} value={agent.id}>
|
||
{agent.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-1.5" title="Treffer unter diesem Wert ausblenden">
|
||
Anzeige ab %
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={100}
|
||
value={displayThreshold}
|
||
onChange={(event) => {
|
||
const value = clamp(event.target.value);
|
||
setDisplayThreshold(value);
|
||
persistThresholds(value, matchThreshold);
|
||
}}
|
||
className={numberInputClass}
|
||
/>
|
||
</label>
|
||
|
||
<label className="flex items-center gap-1.5" title="Ab diesem Wert Ton und Hinweis">
|
||
Hinweis ab %
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={100}
|
||
value={matchThreshold}
|
||
onChange={(event) => {
|
||
const value = clamp(event.target.value);
|
||
setMatchThreshold(value);
|
||
notifyThreshold.current = value;
|
||
persistThresholds(displayThreshold, value);
|
||
}}
|
||
className={numberInputClass}
|
||
/>
|
||
</label>
|
||
|
||
<span className="normal-case tracking-normal">
|
||
{visibleMatches.length} / {matches.length}
|
||
{saveState === 'pending' && ' · speichert…'}
|
||
{saveState === 'saved' && ' · gespeichert'}
|
||
{saveState === 'error' && ' · Fehler'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<Spinner label="Lade Matches..." />
|
||
) : matches.length === 0 ? (
|
||
<EmptyState>
|
||
Noch keine Matches. Lege Suchagenten mit CV und Keywords an und starte einen Scan.
|
||
</EmptyState>
|
||
) : visibleMatches.length === 0 ? (
|
||
<EmptyState>
|
||
Keine Treffer für die aktuellen Filter (Zeitraum, Agent, Anzeige ab {displayThreshold}%).
|
||
</EmptyState>
|
||
) : (
|
||
<div className="scrollbar-slim -mx-5 overflow-x-auto px-5">
|
||
<table className="w-full min-w-[900px] border-collapse text-sm">
|
||
<thead>
|
||
<tr className="border-b border-ink-200">
|
||
{COLUMNS.map((column) => (
|
||
<th
|
||
key={column.key}
|
||
onClick={() => 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' ? ' ▲' : ' ▼')}
|
||
</th>
|
||
))}
|
||
<th className="px-3 py-2.5 text-left text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||
Aktionen
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{visibleMatches.map((match) => (
|
||
<tr
|
||
key={match.id}
|
||
onClick={() => setSelectedMatch(match)}
|
||
className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/50"
|
||
>
|
||
<td className="px-3 py-2.5">
|
||
<MatchBadge value={match.matchPercentage} />
|
||
</td>
|
||
<td className="px-3 py-2.5 font-bold text-ink-800">
|
||
{match.Agent?.name ?? '—'}
|
||
</td>
|
||
<td className="max-w-md px-3 py-2.5">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<p className="truncate font-bold text-ink-900">{match.Project?.title}</p>
|
||
{match.Project?.alreadyApplied && <AlreadyAppliedBadge />}
|
||
</div>
|
||
<p className="truncate text-xs text-ink-400">
|
||
{match.Project?.location || 'Remote'}
|
||
</p>
|
||
</td>
|
||
<td className="px-3 py-2.5 text-ink-600">{match.Project?.portal}</td>
|
||
<td className="px-3 py-2.5">
|
||
<MatchStatusBadge status={match.status} />
|
||
</td>
|
||
<td className="whitespace-nowrap px-3 py-2.5 text-xs text-ink-400">
|
||
{formatDateTime(match.createdAt)}
|
||
</td>
|
||
<td className="px-3 py-2.5" onClick={(event) => event.stopPropagation()}>
|
||
<button
|
||
onClick={() => setSelectedMatch(match)}
|
||
className={secondaryButtonClass}
|
||
>
|
||
Details
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{selectedMatch && (
|
||
<MatchDetailModal
|
||
match={selectedMatch}
|
||
onClose={() => 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 && (
|
||
<ConfirmDialog
|
||
title="Treffer löschen?"
|
||
message={`„${matchToDelete.Project?.title ?? 'Treffer'}" wird endgültig entfernt. Bei einem späteren Scan kann er erneut gefunden werden.`}
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setMatchToDelete(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|