Files
perinco-CRM/src/components/scanner/ScannerDashboard.tsx
T
Sascha WolfandClaude Opus 5.5 69001e9687 feat(scanner): Schwellen im Dashboard pro Benutzer
"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>
2026-10-27 08:50:40 +01:00

690 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};