Files
ICON-CRM/src/components/scanner/ScannerDashboard.tsx
T
schakal00783andClaude Opus 5.5 1e273ddd92 Scanner: Altersfenster "älter als / jünger als" (Tage)
Ersetzt die feste Auswahl bis/über 21 Tage durch zwei freie Felder,
einzeln oder kombiniert (z. B. älter 14, jünger 30). API: olderThan,
youngerThan; age=recent|old bleibt für Altaufrufe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:34:59 +02:00

656 lines
27 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 {
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<ScannerOverview | null>(null);
const [agents, setAgents] = useState<ScannerAgent[]>([]);
const [matches, setMatches] = useState<ScannerMatch[] | null>(null);
const [total, setTotal] = useState(0);
const [detail, setDetail] = useState<ScannerMatch | null>(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<SortState>({ 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 (
<div className="space-y-5">
{/* Kopf */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-ink-500">
Offene Stellen aus Jobbörsen in DACH – jedes Alter, eingrenzen unten mit „Älter als“ / „Jünger als“.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span
className={`inline-flex items-center gap-1.5 rounded border px-2 py-0.5 font-bold uppercase tracking-wide ${
overview?.schedule.active
? 'border-emerald-300 bg-emerald-50 text-emerald-700'
: 'border-ink-200 bg-ink-50 text-ink-500'
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${overview?.schedule.active ? 'bg-emerald-500' : 'bg-ink-300'}`} />
{overview?.schedule.active ? 'Automatische Suche aktiv' : 'Automatische Suche aus'}
</span>
<span className="text-ink-500">{overview?.schedule.active ? scheduleText : ''}</span>
{overview?.lastScanAt && (
<span className="text-ink-400">· letzter Lauf {formatDateTime(overview.lastScanAt)}</span>
)}
{isAdmin && (
<button type="button" onClick={() => goToSettings('scanner')} className="text-brand-600 hover:underline">
Zeitplan ändern
</button>
)}
</div>
</div>
<PrimaryButton type="button" onClick={() => void startScan()} disabled={running} className="flex items-center gap-2">
<svg viewBox="0 0 24 24" className={`h-4 w-4 ${running ? 'animate-spin' : ''}`} fill="currentColor" aria-hidden="true">
<path d="M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8Zm8 8a8 8 0 0 1-15.4 3H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2Z" />
</svg>
{running ? 'Suche läuft …' : 'Jetzt scannen'}
</PrimaryButton>
</div>
{/* Kennzahlen */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Kpi
value={overview?.stats.agents ?? '–'}
label="Aktive Agenten"
icon="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9v-1c0-3 3-5 7-5s7 2 7 5v1H5Z"
/>
<Kpi
value={overview ? `${readySources} / ${overview.sources.length}` : '–'}
label="Börsen bereit"
icon="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm6.9 6h-3a15 15 0 0 0-1.3-3.9A8 8 0 0 1 18.9 8ZM12 4c.8 1.2 1.5 2.5 1.9 4h-3.8c.4-1.5 1.1-2.8 1.9-4ZM4.3 14a8 8 0 0 1 0-4h3.4a17 17 0 0 0 0 4H4.3Zm.8 2h3a15 15 0 0 0 1.3 3.9A8 8 0 0 1 5.1 16Zm3-8h-3a8 8 0 0 1 4.3-3.9C8.9 5.3 8.4 6.6 8.1 8ZM12 20c-.8-1.2-1.5-2.5-1.9-4h3.8c-.4 1.5-1.1 2.8-1.9 4Zm2.3-6H9.7a15 15 0 0 1 0-4h4.6a15 15 0 0 1 0 4Zm.3 5.9c.6-1.2 1-2.5 1.3-3.9h3a8 8 0 0 1-4.3 3.9Zm1.7-5.9a17 17 0 0 0 0-4h3.4a8 8 0 0 1 0 4h-3.4Z"
/>
<Kpi
value={overview?.stats.jobs ?? '–'}
label="Gefundene Anzeigen"
icon="M4 4h16v4H4V4Zm0 6h16v10H4V10Zm3 3v2h10v-2H7Z"
/>
<Kpi
value={overview?.stats.matches ?? '–'}
label={`Treffer · ${overview?.stats.known ?? 0} bei bekannten Firmen`}
icon="M12 2 3 7v10l9 5 9-5V7l-9-5Zm-1 14-4-4 1.4-1.4 2.6 2.6 5.6-5.6L18 9l-7 7Z"
/>
</div>
{failing.length > 0 && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800">
Im letzten Lauf ohne Ergebnis: {failing.map((item) => item.label).join(', ')} –{' '}
{failing[0].last?.errors[0] ?? 'unbekannter Fehler'}
</p>
)}
{/* Trefferliste */}
<section className="rounded-xl border border-ink-200 bg-surface">
<div className="flex flex-wrap items-end gap-3 border-b border-ink-200 p-4">
<h2 className="mr-auto text-sm font-bold uppercase tracking-wider text-ink-900">Stellen-Treffer</h2>
<Filter label="Gefunden" className="w-44">
<select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
{PERIODS.map((period) => (
<option key={period.value} value={period.value}>
{period.label}
</option>
))}
</select>
</Filter>
<Filter label="Agent" className="w-44">
<select value={agentId} onChange={(event) => setAgentId(event.target.value)} className={inputClass}>
<option value="">alle</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</Filter>
<Filter label="Börse" className="w-40">
<select value={source} onChange={(event) => setSource(event.target.value)} className={inputClass}>
<option value="">alle</option>
{SOURCE_ORDER.map((id) => (
<option key={id} value={id}>
{SOURCE_LABELS[id]}
</option>
))}
</select>
</Filter>
<Filter label="Älter als (Tage)" className="w-28">
<input
type="number"
min={0}
value={olderThan}
onChange={(event) => setOlderThan(event.target.value)}
placeholder="–"
title="Nur Anzeigen, die mindestens so viele Tage alt sind"
className={inputClass}
/>
</Filter>
<Filter label="Jünger als (Tage)" className="w-28">
<input
type="number"
min={0}
value={youngerThan}
onChange={(event) => 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'
: ''
}`}
/>
</Filter>
<Filter label="Ab %" className="w-20">
<input
type="number"
min={0}
max={100}
step={5}
value={minScore}
onChange={(event) => setMinScore(Number(event.target.value) || 0)}
className={inputClass}
/>
</Filter>
<Filter label="Sortierung" className="w-48">
<select
value={presetOf(sort)}
onChange={(event) => {
const preset = PRESETS[event.target.value];
if (preset) setSort(preset.sort);
}}
className={inputClass}
>
{Object.entries(PRESETS).map(([value, preset]) => (
<option key={value} value={value}>
{preset.label}
</option>
))}
{presetOf(sort) === '' && <option value="">nach Spalte</option>}
</select>
</Filter>
<Filter label="Suche" className="w-44">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Titel, Firma, Ort"
className={inputClass}
/>
</Filter>
<div className="flex flex-col gap-1 pb-1 text-sm text-ink-600">
<label className="flex items-center gap-2">
<input type="checkbox" checked={knownOnly} onChange={(event) => setKnownOnly(event.target.checked)} />
nur bekannte Firmen
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={alarmOnly} onChange={(event) => setAlarmOnly(event.target.checked)} />
nur Alarm
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} />
verworfene zeigen
</label>
</div>
<span className="pb-2 text-xs text-ink-400">
{matches?.length ?? 0} / {total}
</span>
</div>
{matches === null ? (
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : matches.length === 0 ? (
<div className="py-16 text-center text-sm text-ink-400">
{agents.length === 0 ? (
<>Noch keine Suchagenten angelegt – unter <em>Scanner → Suchagenten</em> geht es los.</>
) : (
'Keine Treffer für diese Auswahl.'
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-500">
<SortHeader label="Match" column="score" sort={sort} onSort={setSort} />
<SortHeader label="Agent" column="agent" sort={sort} onSort={setSort} />
<SortHeader label="Stelle" column="title" sort={sort} onSort={setSort} />
<SortHeader label="Unternehmen" column="company" sort={sort} onSort={setSort} />
<SortHeader label="Börse" column="source" sort={sort} onSort={setSort} />
<SortHeader label="Veröffentlicht" column="published" sort={sort} onSort={setSort} />
<SortHeader label="Status" column="status" sort={sort} onSort={setSort} />
<SortHeader label="Gefunden am" column="created" sort={sort} onSort={setSort} />
<th className="px-4 py-3 text-right">Aktionen</th>
</tr>
</thead>
<tbody>
{matches.map((match) => (
<tr
key={match.id}
className={`border-b border-ink-100 last:border-0 hover:bg-ink-50 ${
match.known ? 'bg-brand-50/40' : ''
} ${match.status === 'verworfen' ? 'opacity-60' : ''}`}
>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<ScoreBox score={match.score} />
{match.alarm && <AlarmBell />}
</div>
</td>
<td className="px-4 py-3 font-semibold text-ink-900">{match.agent_name}</td>
<td className="max-w-md px-4 py-3">
<button
type="button"
onClick={() => openDetail(match)}
className="text-left font-semibold text-ink-900 hover:text-brand-700"
>
{match.title}
</button>
<p className="mt-0.5 text-xs text-ink-500">
{[match.location, match.country, match.employment_type].filter(Boolean).join(' · ') || '–'}
</p>
</td>
<td className="px-4 py-3">
<span className="text-ink-800">{match.company ?? '–'}</span>
{match.known && match.company_id && (
<button
type="button"
onClick={() => open('company', match.company_id!)}
title={`Im CRM: ${match.company_name}`}
className={`ml-2 inline-flex rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
match.is_customer
? 'border-emerald-300 bg-emerald-50 text-emerald-700'
: 'border-brand-300 bg-brand-50 text-brand-700'
}`}
>
{match.is_customer ? 'Kunde' : 'Bekannt'}
</button>
)}
</td>
<td className="px-4 py-3 text-ink-600">{SOURCE_LABELS[match.source] ?? match.source}</td>
<td
className="whitespace-nowrap px-4 py-3 text-ink-500"
title={match.published_at ? formatDateTime(match.published_at) : 'Die Börse nennt kein Datum'}
>
{formatAge(match.published_at)}
</td>
<td className="px-4 py-3">
<StatusBadge status={match.status} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-ink-500">{formatDateTime(match.created_at)}</td>
<td className="px-4 py-3 text-right">
<button
type="button"
onClick={() => openDetail(match)}
className="rounded-md border border-ink-200 px-3 py-1 text-xs font-semibold text-ink-700 hover:bg-ink-100"
>
Details
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<MatchDetail
match={detail}
onClose={() => setDetail(null)}
onStatus={(status) => detail && void setStatus(detail, status)}
onOpenCompany={(id) => {
setDetail(null);
open('company', id);
}}
/>
</div>
);
};
const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = ({ value, label, icon }) => (
<div className="flex items-center gap-4 rounded-xl border border-ink-200 bg-surface p-5">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<path d={icon} />
</svg>
</span>
<div className="min-w-0">
<p className="text-2xl font-bold tabular-nums text-ink-900">{value}</p>
<p className="truncate text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</p>
</div>
</div>
);
/* ---------------------------------------------------------- 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<string, { label: string; sort: SortState }> = {
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 (
<th className="px-4 py-3" aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
<button
type="button"
onClick={() => onSort(next())}
title="Nach dieser Spalte sortieren"
className={`inline-flex items-center gap-1 uppercase tracking-wider hover:text-ink-900 ${
active ? 'text-brand-700' : ''
}`}
>
{label}
<span className={`text-[10px] ${active ? '' : 'opacity-0'}`} aria-hidden="true">
{active && sort.dir === 'asc' ? '▲' : '▼'}
</span>
</button>
</th>
);
};
const Filter: React.FC<{ label: string; className?: string; children: React.ReactNode }> = ({ label, className = '', children }) => (
<label className={`flex shrink-0 flex-col gap-1 ${className}`}>
<span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span>
{children}
</label>
);
/** 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 ? (
<mark key={index} className="rounded bg-brand-100 px-0.5 text-brand-800">
{part}
</mark>
) : (
part
)
);
}
const MatchDetail: React.FC<{
match: ScannerMatch | null;
onClose: () => void;
onStatus: (status: ScannerMatchStatus) => void;
onOpenCompany: (id: number) => void;
}> = ({ match, onClose, onStatus, onOpenCompany }) => (
<Modal
open={Boolean(match)}
onClose={onClose}
size="lg"
title={match?.title ?? ''}
subtitle={
match
? [match.company, match.location, match.country, SOURCE_LABELS[match.source]].filter(Boolean).join(' · ')
: undefined
}
footer={
match && (
<>
<SecondaryButton type="button" onClick={onClose}>
Schließen
</SecondaryButton>
<a
href={match.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
Anzeige öffnen ↗
</a>
</>
)
}
>
{match && (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-3">
<ScoreBox score={match.score} />
<div className="text-sm text-ink-600">
Agent <strong className="text-ink-900">{match.agent_name}</strong> · veröffentlicht{' '}
{formatAge(match.published_at)} · gefunden {formatDateTime(match.created_at)}
</div>
</div>
<div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Gefundene Schlagwörter</p>
<div className="flex flex-wrap gap-1.5">
{match.hits.map((hit) => (
<span key={hit} className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
{hit}
</span>
))}
</div>
</div>
{match.known && match.company_id && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-brand-200 bg-brand-50 px-4 py-3 text-sm">
<span className="text-brand-800">
<strong>{match.company_name}</strong> steht bereits im CRM
{match.is_customer ? ' – als Kunde.' : '.'}
</span>
<button
type="button"
onClick={() => onOpenCompany(match.company_id!)}
className="font-semibold text-brand-700 hover:underline"
>
Unternehmen öffnen →
</button>
</div>
)}
<div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Auszug</p>
<p className="whitespace-pre-line text-sm leading-relaxed text-ink-700">
{match.description ? highlight(match.description, match.hits) : 'Die Börse liefert in der Trefferliste keinen Text – Details in der Anzeige.'}
</p>
{match.employment_type && <p className="mt-2 text-xs text-ink-500">Art: {match.employment_type}</p>}
</div>
<div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Status</p>
<div className="flex flex-wrap gap-2">
{(Object.keys(STATUS_LABELS) as ScannerMatchStatus[]).map((status) => (
<button
key={status}
type="button"
onClick={() => onStatus(status)}
className={`rounded-lg border px-3 py-1.5 text-sm ${
match.status === status
? 'border-brand-500 bg-brand-50 font-semibold text-brand-700'
: 'border-ink-200 text-ink-600 hover:bg-ink-50'
}`}
>
{STATUS_LABELS[status]}
</button>
))}
</div>
</div>
</div>
)}
</Modal>
);