- 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>
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
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>
|
||
);
|