Files
ICON-CRM/src/components/scanner/ScannerAgents.tsx
T
schakal00783andClaude Opus 5.5 1ad774fdd2 Scanner: ohne Altersgrenze suchen, Filter Anzeigenalter, Alarm-Schlagwörter
- Suche ohne 21-Tage-Grenze (BA ohne veroeffentlichtseit, Adzuna ohne
  max_days_old); Einstellung Höchstalter entfällt
- Dashboard: Filter Anzeigenalter (bis/älter als 21 Tage), Sortierung
  älteste Anzeige, Filter nur Alarm, Zeitraum heißt jetzt Gefunden
- Suchagent: Schlagwörter mit Alarm; Treffer mit alarm = 1 melden sich
  über den AlarmWatcher (App-weit, jede Minute) mit Ton und Pop-up,
  Quittieren gilt für alle

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

360 lines
14 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, { useEffect, useState } from 'react';
import {
scannerApi,
type ScannerAgent,
type ScannerAgentInput,
type ScannerOverview,
type ScannerSourceId,
} from '../../lib/api';
import { useAuth } from '../../lib/auth';
import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast';
import { ConfirmDialog, Modal } from '../ui/Modal';
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
/*
* Scanner -> Suchagenten.
*
* Ein Agent = Schlagwörter + Börsen + Ausschlussbegriffe. Jedes Schlagwort
* wird in jeder gewählten Börse einzeln gesucht; eine Anzeige, die einen
* Ausschlussbegriff enthält, wird gar nicht erst gespeichert.
*/
const EMPTY: ScannerAgentInput = {
name: '',
keywords: [],
sources: ['ba', 'stepstone', 'karriere'],
excludes: [],
alarm_keywords: [],
is_active: true,
};
export const ScannerAgents: React.FC = () => {
const { toast } = useToast();
const { isAdmin } = useAuth();
const { goToSettings } = useNavigation();
const [agents, setAgents] = useState<ScannerAgent[] | null>(null);
const [sources, setSources] = useState<ScannerOverview['sources']>([]);
const [editing, setEditing] = useState<{ id: number | null; value: ScannerAgentInput } | null>(null);
const [removing, setRemoving] = useState<ScannerAgent | null>(null);
const [busy, setBusy] = useState(false);
const load = async () => {
try {
const [{ agents: list }, overview] = await Promise.all([scannerApi.agents(), scannerApi.overview()]);
setAgents(list);
setSources(overview.sources);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const ready = (id: ScannerSourceId) => sources.find((item) => item.id === id)?.ready ?? true;
const save = async () => {
if (!editing) return;
setBusy(true);
try {
if (editing.id) await scannerApi.updateAgent(editing.id, editing.value);
else await scannerApi.createAgent(editing.value);
toast('Suchagent gespeichert – er sucht ab dem nächsten Lauf mit.', 'success');
setEditing(null);
void load();
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setBusy(false);
}
};
const toggle = async (agent: ScannerAgent) => {
try {
await scannerApi.updateAgent(agent.id, { ...agent, is_active: !agent.is_active });
void load();
} catch (err) {
toast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
}
};
const remove = async () => {
if (!removing) return;
try {
await scannerApi.removeAgent(removing.id);
toast('Suchagent gelöscht.', 'success');
setRemoving(null);
void load();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
}
};
const value = editing?.value;
const patch = (changes: Partial<ScannerAgentInput>) =>
setEditing((current) => (current ? { ...current, value: { ...current.value, ...changes } } : current));
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-3xl text-sm text-ink-500">
Jeder Agent sucht seine Schlagwörter in den gewählten Börsen. Je mehr Schlagwörter in einer Anzeige
vorkommen, desto höher die Quote – ein Treffer im Titel zählt doppelt. Anzeigen mit einem
Ausschlussbegriff fallen raus.
</p>
<PrimaryButton type="button" onClick={() => setEditing({ id: null, value: EMPTY })}>
+ Neuer Suchagent
</PrimaryButton>
</div>
{agents === null ? (
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
) : agents.length === 0 ? (
<p className="rounded-xl border border-dashed border-ink-200 py-16 text-center text-sm text-ink-400">
Noch keine Suchagenten.
</p>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{agents.map((agent) => (
<article
key={agent.id}
className={`rounded-xl border border-ink-200 bg-surface p-5 ${agent.is_active ? '' : 'opacity-60'}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-ink-900">{agent.name}</h3>
<p className="text-xs text-ink-400">
{agent.is_active ? 'aktiv' : 'pausiert'} · {agent.matches} Treffer
</p>
</div>
<div className="flex gap-1.5">
<SecondaryButton type="button" className="px-3 py-1 text-xs" onClick={() => void toggle(agent)}>
{agent.is_active ? 'Pausieren' : 'Aktivieren'}
</SecondaryButton>
<SecondaryButton
type="button"
className="px-3 py-1 text-xs"
onClick={() =>
setEditing({
id: agent.id,
value: {
name: agent.name,
keywords: agent.keywords,
sources: agent.sources,
excludes: agent.excludes,
alarm_keywords: agent.alarm_keywords,
is_active: agent.is_active,
},
})
}
>
Bearbeiten
</SecondaryButton>
<button
type="button"
onClick={() => setRemoving(agent)}
className="rounded-lg px-2 text-xs text-red-600 hover:bg-red-50"
>
Löschen
</button>
</div>
</div>
<Row label="Schlagwörter">
{agent.keywords.map((term) => (
<span key={term} className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs text-brand-700">
{term}
</span>
))}
</Row>
{agent.alarm_keywords.length > 0 && (
<Row label="Schlagwörter mit Alarm">
{agent.alarm_keywords.map((term) => (
<span
key={term}
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
>
<AlarmBell className="h-3 w-3" />
{term}
</span>
))}
</Row>
)}
<Row label="Börsen">
{agent.sources.map((id) => (
<span
key={id}
title={ready(id) ? undefined : 'Schlüssel fehlt – Börse wird übersprungen'}
className={`rounded-full border px-2 py-0.5 text-xs ${
ready(id) ? 'border-ink-200 bg-ink-50 text-ink-600' : 'border-amber-200 bg-amber-50 text-amber-700'
}`}
>
{SOURCE_LABELS[id] ?? id}
{!ready(id) && ' ⚠'}
</span>
))}
</Row>
{agent.excludes.length > 0 && (
<Row label="Ausschluss">
{agent.excludes.map((term) => (
<span key={term} className="rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs text-red-700">
{term}
</span>
))}
</Row>
)}
</article>
))}
</div>
)}
<Modal
open={Boolean(editing)}
onClose={() => setEditing(null)}
size="md"
title={editing?.id ? 'Suchagent bearbeiten' : 'Neuer Suchagent'}
footer={
<>
<SecondaryButton type="button" onClick={() => setEditing(null)}>
Abbrechen
</SecondaryButton>
<PrimaryButton
type="button"
disabled={busy || !value?.name.trim() || !(value?.keywords.length || value?.alarm_keywords.length) || !value?.sources.length}
onClick={() => void save()}
>
Speichern
</PrimaryButton>
</>
}
>
{value && (
<div className="space-y-5">
<Field label="Name" hint="Z. B. die Rolle oder der Berater, für den gesucht wird.">
<input
value={value.name}
onChange={(event) => patch({ name: event.target.value })}
placeholder="Interim CFO"
className={inputClass}
/>
</Field>
<Field label="Schlagwörter" hint="Enter oder Komma trennt. Jedes Schlagwort ist eine eigene Suche je Börse.">
<TermInput
value={value.keywords}
onChange={(keywords) => patch({ keywords })}
placeholder="z. B. Interim CFO, Finanzleiter, SAP FI"
/>
</Field>
<Field
label="Schlagwörter mit Alarm"
hint="Werden gesucht und gezählt wie Schlagwörter. Wird eines gefunden, gibt es kurz einen Ton und ein Pop-up – in jeder Ansicht des CRM."
>
<TermInput
value={value.alarm_keywords}
onChange={(alarm_keywords) => patch({ alarm_keywords })}
placeholder="z. B. Interim CFO"
tone="alarm"
/>
</Field>
<Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
<TermInput
value={value.excludes}
onChange={(excludes) => patch({ excludes })}
placeholder="z. B. Praktikum, Werkstudent, Ausbildung"
tone="red"
/>
</Field>
<div>
<p className="field-label mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-500">Börsen</p>
<div className="grid gap-2 sm:grid-cols-2">
{SOURCE_ORDER.map((id) => {
const info = sources.find((item) => item.id === id);
const checked = value.sources.includes(id);
return (
<label
key={id}
className={`flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 text-sm ${
checked ? 'border-brand-400 bg-brand-50' : 'border-ink-200'
}`}
>
<input
type="checkbox"
checked={checked}
className="mt-0.5"
onChange={() =>
patch({
sources: checked ? value.sources.filter((item) => item !== id) : [...value.sources, id],
})
}
/>
<span>
<span className="font-medium text-ink-900">{SOURCE_LABELS[id]}</span>
<span className="block text-xs text-ink-400">
{info?.countries}
{info && !info.ready && ' · Schlüssel fehlt'}
</span>
</span>
</label>
);
})}
</div>
{sources.some((item) => !item.ready) && (
<p className="mt-2 text-xs text-ink-400">
Adzuna braucht einen kostenlosen API-Schlüssel
{isAdmin ? (
<>
{' '}
–{' '}
<button
type="button"
className="text-brand-600 hover:underline"
onClick={() => {
setEditing(null);
goToSettings('scanner');
}}
>
Einstellungen → Scanner
</button>
</>
) : (
' (Administrator: Einstellungen → Scanner)'
)}
. Ohne Schlüssel wird es übersprungen.
</p>
)}
</div>
<label className="flex items-center gap-2 text-sm text-ink-700">
<input type="checkbox" checked={value.is_active} onChange={(event) => patch({ is_active: event.target.checked })} />
aktiv
</label>
</div>
)}
</Modal>
<ConfirmDialog
open={Boolean(removing)}
title="Suchagent löschen?"
message={`„${removing?.name}" und seine ${removing?.matches ?? 0} Treffer werden gelöscht.`}
onConfirm={() => void remove()}
onCancel={() => setRemoving(null)}
/>
</div>
);
};
const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div className="mt-3">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-ink-400">{label}</p>
<div className="flex flex-wrap gap-1.5">{children}</div>
</div>
);