feat: Projekt Scanner integrieren, Whisper in die Einstellungen
Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,679 @@
|
||||
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,
|
||||
} from './scannerUi';
|
||||
import {
|
||||
SCANNER_SETTINGS_DEFAULTS,
|
||||
scannerApi,
|
||||
type MatchStatus,
|
||||
type ScannerAgent,
|
||||
type ScannerMatch,
|
||||
type ScannerSettings,
|
||||
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>('matchPercentage');
|
||||
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] = 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),
|
||||
]);
|
||||
|
||||
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);
|
||||
|
||||
const parsedDisplay = parseInt(
|
||||
settings.display_threshold ?? SCANNER_SETTINGS_DEFAULTS.display_threshold,
|
||||
10
|
||||
);
|
||||
if (!Number.isNaN(parsedDisplay)) setDisplayThreshold(parsedDisplay);
|
||||
|
||||
const parsedNotify = 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 liegen in den Einstellungen - 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.saveSettings({
|
||||
display_threshold: String(display),
|
||||
match_threshold: String(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">
|
||||
<p className="truncate font-bold text-ink-900">{match.Project?.title}</p>
|
||||
<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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user