import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { scannerApi, type ScannerAgent, type ScannerMatch, type ScannerMatchStatus, type ScannerOverview, } from '../../lib/api'; import { useAuth } from '../../lib/auth'; import { useNavigation } from '../../lib/navigation'; import { useToast } from '../Toast'; import { Modal } from '../ui/Modal'; import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form'; import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, STATUS_LABELS, ScoreBox, StatusBadge, WEEKDAY_LABELS, formatAge, formatDateTime, } from './scannerUi'; /* * Scanner -> Dashboard. * * Kennzahlen, Zustand der Börsen und die Trefferliste. Vorbild ist das * Dashboard des perinco-Scanners; statt Lebenslauf-Abgleich zeigt die Quote, * wie viele Schlagwörter des Agenten in der Anzeige stehen. * * Treffer bei Unternehmen, die schon im CRM stehen, stehen oben und tragen * ein Abzeichen - das ist der eigentliche Wert: eine offene Stelle bei einem * Kunden oder bekannten Unternehmen ist ein Anlass anzurufen. */ const PERIODS = [ { value: 24, label: 'letzte 24 Stunden' }, { value: 72, label: 'letzte 3 Tage' }, { value: 168, label: 'letzte 7 Tage' }, { value: 504, label: 'letzte 21 Tage' }, { value: 0, label: 'alle' }, ]; const POLL_MS = 30_000; const POLL_RUNNING_MS = 5_000; export const ScannerDashboard: React.FC = () => { const { toast } = useToast(); const { isAdmin } = useAuth(); const { open, goToSettings } = useNavigation(); const [overview, setOverview] = useState(null); const [agents, setAgents] = useState([]); const [matches, setMatches] = useState(null); const [total, setTotal] = useState(0); const [detail, setDetail] = useState(null); const [hours, setHours] = useState(168); const [agentId, setAgentId] = useState(''); const [source, setSource] = useState(''); const [minScore, setMinScore] = useState(0); const [knownOnly, setKnownOnly] = useState(false); const [showDiscarded, setShowDiscarded] = useState(false); const [search, setSearch] = useState(''); const [sort, setSort] = useState({ key: 'known', dir: 'desc' }); /** Altersfenster der Anzeige in Tagen; leer = keine Grenze. */ const [olderThan, setOlderThan] = useState(''); const [youngerThan, setYoungerThan] = useState(''); const [alarmOnly, setAlarmOnly] = useState(false); const wasRunning = useRef(false); const loadMatches = useCallback(async () => { try { const data = await scannerApi.matches({ hours: hours || undefined, agentId: agentId || undefined, source: source || undefined, minScore: minScore || undefined, known: knownOnly ? 1 : undefined, status: showDiscarded ? 'alle' : undefined, q: search.trim() || undefined, olderThan: Number(olderThan) > 0 ? Number(olderThan) : undefined, youngerThan: Number(youngerThan) > 0 ? Number(youngerThan) : undefined, alarm: alarmOnly ? 1 : undefined, sort: sort.key, dir: sort.dir, }); setMatches(data.matches); setTotal(data.total); } catch (err) { toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error'); } }, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, olderThan, youngerThan, alarmOnly, sort, toast]); const loadOverview = useCallback(async () => { try { const data = await scannerApi.overview(); setOverview(data); // Lauf gerade fertig geworden -> Liste auffrischen. if (wasRunning.current && !data.status.running) { const run = data.lastRun; toast( run ? `Suchlauf fertig: ${run.jobs_found} Anzeigen, ${run.matches_new} neue Treffer.` : 'Suchlauf fertig.', 'success' ); void loadMatches(); } wasRunning.current = data.status.running; } catch { /* Übersicht ist Beiwerk - die Liste meldet eigene Fehler. */ } }, [loadMatches, toast]); useEffect(() => { void loadOverview(); scannerApi .agents() .then((data) => setAgents(data.agents)) .catch(() => undefined); }, [loadOverview]); useEffect(() => { const handle = setTimeout(() => void loadMatches(), search ? 300 : 0); return () => clearTimeout(handle); }, [loadMatches, search]); const running = overview?.status.running ?? false; useEffect(() => { const handle = setInterval(() => void loadOverview(), running ? POLL_RUNNING_MS : POLL_MS); return () => clearInterval(handle); }, [loadOverview, running]); const startScan = async () => { try { await scannerApi.scan(); wasRunning.current = true; toast('Suchlauf gestartet – das dauert ein paar Minuten.', 'success'); void loadOverview(); } catch (err) { toast(err instanceof Error ? err.message : 'Start fehlgeschlagen.', 'error'); } }; const setStatus = async (match: ScannerMatch, status: ScannerMatchStatus) => { try { await scannerApi.setStatus(match.id, status); setMatches((list) => list?.map((item) => (item.id === match.id ? { ...item, status } : item)) ?? null); setDetail((current) => (current?.id === match.id ? { ...current, status } : current)); } catch (err) { toast(err instanceof Error ? err.message : 'Status nicht gespeichert.', 'error'); } }; const openDetail = (match: ScannerMatch) => { setDetail(match); if (match.status === 'neu') void setStatus(match, 'gesehen'); }; const readySources = overview?.sources.filter((item) => item.ready).length ?? 0; const failing = overview?.sources.filter((item) => item.last && !item.last.ok) ?? []; const scheduleText = useMemo(() => { if (!overview) return ''; const s = overview.schedule; if (!s.active) return 'Automatische Suche aus'; const days = s.days.map((d) => WEEKDAY_LABELS[d - 1]).join(', '); const every = s.interval % 60 === 0 ? `${s.interval / 60 === 1 ? 'stündlich' : `alle ${s.interval / 60} h`}` : `alle ${s.interval} min`; return `${every} · ${days} · ${s.from}–${s.to} Uhr`; }, [overview]); return (
{/* Kopf */}

Offene Stellen aus Jobbörsen in DACH – jedes Alter, eingrenzen unten mit „Älter als“ / „Jünger als“.

{overview?.schedule.active ? 'Automatische Suche aktiv' : 'Automatische Suche aus'} {overview?.schedule.active ? scheduleText : ''} {overview?.lastScanAt && ( · letzter Lauf {formatDateTime(overview.lastScanAt)} )} {isAdmin && ( )}
void startScan()} disabled={running} className="flex items-center gap-2"> {running ? 'Suche läuft …' : 'Jetzt scannen'}
{/* Kennzahlen */}
{failing.length > 0 && (

Im letzten Lauf ohne Ergebnis: {failing.map((item) => item.label).join(', ')} –{' '} {failing[0].last?.errors[0] ?? 'unbekannter Fehler'}

)} {/* Trefferliste */}

Stellen-Treffer

setOlderThan(event.target.value)} placeholder="–" title="Nur Anzeigen, die mindestens so viele Tage alt sind" className={inputClass} /> setYoungerThan(event.target.value)} placeholder="–" title="Nur Anzeigen, die höchstens so viele Tage alt sind" className={`${inputClass} ${ Number(olderThan) > 0 && Number(youngerThan) > 0 && Number(youngerThan) <= Number(olderThan) ? 'border-red-400' : '' }`} /> setMinScore(Number(event.target.value) || 0)} className={inputClass} /> setSearch(event.target.value)} placeholder="Titel, Firma, Ort" className={inputClass} />
{matches?.length ?? 0} / {total}
{matches === null ? (

Wird geladen …

) : matches.length === 0 ? (
{agents.length === 0 ? ( <>Noch keine Suchagenten angelegt – unter Scanner → Suchagenten geht es los. ) : ( 'Keine Treffer für diese Auswahl.' )}
) : (
{matches.map((match) => ( ))}
Aktionen
{match.alarm && }
{match.agent_name}

{[match.location, match.country, match.employment_type].filter(Boolean).join(' · ') || '–'}

{match.company ?? '–'} {match.known && match.company_id && ( )} {SOURCE_LABELS[match.source] ?? match.source} {formatAge(match.published_at)} {formatDateTime(match.created_at)}
)}
setDetail(null)} onStatus={(status) => detail && void setStatus(detail, status)} onOpenCompany={(id) => { setDetail(null); open('company', id); }} />
); }; const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = ({ value, label, icon }) => (

{value}

{label}

); /* ---------------------------------------------------------- Sortierung */ type SortKey = 'known' | 'score' | 'agent' | 'title' | 'company' | 'source' | 'published' | 'status' | 'created'; type SortState = { key: SortKey; dir: 'asc' | 'desc' }; /** Schnellwahl im Feld "Sortierung" - dieselben Werte wie ein Spaltenklick. */ const PRESETS: Record = { known: { label: 'bekannte Firmen zuerst', sort: { key: 'known', dir: 'desc' } }, score: { label: 'beste Quote', sort: { key: 'score', dir: 'desc' } }, published: { label: 'neueste Anzeige', sort: { key: 'published', dir: 'desc' } }, oldest: { label: 'älteste Anzeige', sort: { key: 'published', dir: 'asc' } }, }; const presetOf = (sort: SortState) => Object.entries(PRESETS).find(([, preset]) => preset.sort.key === sort.key && preset.sort.dir === sort.dir)?.[0] ?? ''; /** Texte beginnen beim ersten Klick mit A-Z, Zahlen und Daten mit dem größten/neuesten Wert. */ const TEXT_COLUMNS: SortKey[] = ['agent', 'title', 'company', 'source', 'status']; const SortHeader: React.FC<{ label: string; column: SortKey; sort: SortState; onSort: (sort: SortState) => void; }> = ({ label, column, sort, onSort }) => { const active = sort.key === column; const next = (): SortState => active ? { key: column, dir: sort.dir === 'asc' ? 'desc' : 'asc' } : { key: column, dir: TEXT_COLUMNS.includes(column) ? 'asc' : 'desc' }; return ( ); }; const Filter: React.FC<{ label: string; className?: string; children: React.ReactNode }> = ({ label, className = '', children }) => ( ); /** Schlagwörter im Text hervorheben. */ function highlight(text: string, terms: string[]): React.ReactNode { if (!text || terms.length === 0) return text; const pattern = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi'); return text.split(pattern).map((part, index) => index % 2 === 1 ? ( {part} ) : ( part ) ); } const MatchDetail: React.FC<{ match: ScannerMatch | null; onClose: () => void; onStatus: (status: ScannerMatchStatus) => void; onOpenCompany: (id: number) => void; }> = ({ match, onClose, onStatus, onOpenCompany }) => ( Schließen Anzeige öffnen ↗ ) } > {match && (
Agent {match.agent_name} · veröffentlicht{' '} {formatAge(match.published_at)} · gefunden {formatDateTime(match.created_at)}

Gefundene Schlagwörter

{match.hits.map((hit) => ( {hit} ))}
{match.known && match.company_id && (
{match.company_name} steht bereits im CRM {match.is_customer ? ' – als Kunde.' : '.'}
)}

Auszug

{match.description ? highlight(match.description, match.hits) : 'Die Börse liefert in der Trefferliste keinen Text – Details in der Anzeige.'}

{match.employment_type &&

Art: {match.employment_type}

}

Status

{(Object.keys(STATUS_LABELS) as ScannerMatchStatus[]).map((status) => ( ))}
)}
);