Neu gefundene Projekte mit Treffer werden einmalig per gpt-5.6-luna mit den Akquise-Eintraegen und Opportunities der letzten N Tage (Vorgabe 14) verglichen. Passt ein Eintrag, zeigt der Scanner "bereits beworben!" samt Begruendung. Schalter und Zeitraum (Tage/Wochen) in Einstellungen -> Scanner, Neupruefung von Hand im Detail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { inputClass, labelClass } from '../leads/leadUi';
|
||
import { useToast } from '../Toast';
|
||
import {
|
||
SCANNER_SETTINGS_DEFAULTS,
|
||
SECRET_MASK,
|
||
scannerApi,
|
||
type ScannerSettings as ScannerSettingsValues,
|
||
} from '../../lib/scannerApi';
|
||
|
||
/*
|
||
* Alle Einstellungen des Projekt Scanners an einer Stelle. Anzeige- und
|
||
* Benachrichtigungs-Schwellenwert lassen sich zusaetzlich direkt im
|
||
* Scanner-Dashboard verstellen - es ist derselbe Wert.
|
||
*/
|
||
|
||
const Section: React.FC<{
|
||
title: string;
|
||
description?: string;
|
||
children: React.ReactNode;
|
||
accent?: string;
|
||
}> = ({ title, description, children, accent }) => (
|
||
<section
|
||
className={`rounded-2xl border border-ink-200 bg-surface p-5 ${
|
||
accent ? `border-l-4 ${accent}` : ''
|
||
}`}
|
||
>
|
||
<h3 className="text-base text-ink-900">{title}</h3>
|
||
{description && <p className="mt-1 text-xs text-ink-400">{description}</p>}
|
||
<div className="mt-4">{children}</div>
|
||
</section>
|
||
);
|
||
|
||
const Checkbox: React.FC<{
|
||
checked: boolean;
|
||
onChange: (checked: boolean) => void;
|
||
label: string;
|
||
}> = ({ checked, onChange, label }) => (
|
||
<label className="flex cursor-pointer items-center gap-3 rounded-xl border border-ink-200 px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={checked}
|
||
onChange={(event) => onChange(event.target.checked)}
|
||
className="h-4 w-4 accent-brand-500"
|
||
/>
|
||
<span className="text-sm font-semibold text-ink-700">{label}</span>
|
||
</label>
|
||
);
|
||
|
||
export const ScannerSettings: React.FC = () => {
|
||
const { addToast } = useToast();
|
||
const [values, setValues] = useState<ScannerSettingsValues>(SCANNER_SETTINGS_DEFAULTS);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
/** Nur Anzeige - gespeichert werden immer Tage. */
|
||
const [periodUnit, setPeriodUnit] = useState<'days' | 'weeks'>('weeks');
|
||
|
||
const load = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const data = await scannerApi.settings();
|
||
const merged = { ...SCANNER_SETTINGS_DEFAULTS, ...data };
|
||
setValues(merged);
|
||
setPeriodUnit(Number(merged.applied_check_days) % 7 === 0 ? 'weeks' : 'days');
|
||
} catch (err) {
|
||
addToast(
|
||
err instanceof Error ? err.message : 'Scanner-Einstellungen nicht erreichbar.',
|
||
'error'
|
||
);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
load();
|
||
// Einmal beim Oeffnen laden - addToast ist stabil, load braucht keine Deps.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const set = <K extends keyof ScannerSettingsValues>(key: K, value: string) =>
|
||
setValues((prev) => ({ ...prev, [key]: value }));
|
||
|
||
const setFlag = (key: keyof ScannerSettingsValues, checked: boolean) =>
|
||
set(key, checked ? 'true' : 'false');
|
||
|
||
const handleSubmit = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
setIsSaving(true);
|
||
try {
|
||
await scannerApi.saveSettings(values);
|
||
addToast('Scanner-Einstellungen gespeichert.', 'success');
|
||
// Neu laden, damit Geheimnisse wieder maskiert erscheinen.
|
||
await load();
|
||
} catch (err) {
|
||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex justify-center py-14">
|
||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isScanningActive = values.scanning_active === 'true';
|
||
const emailActive = values.email_notifications_active === 'true';
|
||
const keyStored = values.openai_api_key === SECRET_MASK;
|
||
const appliedCheckActive = values.applied_check_active === 'true';
|
||
const periodDays = Math.max(1, Number.parseInt(values.applied_check_days, 10) || 14);
|
||
const periodFactor = periodUnit === 'weeks' ? 7 : 1;
|
||
const periodValue = Math.max(1, Math.round(periodDays / periodFactor));
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-5">
|
||
<Section
|
||
title="Scanner-Status"
|
||
description="Hauptschalter für den Hintergrund-Scanner. Pausiert durchsucht die App keine Jobbörsen mehr, auch der manuelle Scan ist dann blockiert."
|
||
accent={isScanningActive ? 'border-l-brand-500' : 'border-l-ink-300'}
|
||
>
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<span
|
||
className={`inline-flex items-center rounded-md border px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider ${
|
||
isScanningActive
|
||
? 'border-brand-200 bg-brand-50 text-brand-700'
|
||
: 'border-ink-200 bg-ink-100 text-ink-500'
|
||
}`}
|
||
>
|
||
{isScanningActive ? '● Aktiv' : '○ Pausiert'}
|
||
</span>
|
||
</div>
|
||
<Checkbox
|
||
checked={isScanningActive}
|
||
onChange={(checked) => setFlag('scanning_active', checked)}
|
||
label="Scanner aktiviert"
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="Suche">
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
<div>
|
||
<label className={labelClass}>Suchintervall (Minuten)</label>
|
||
<input
|
||
type="number"
|
||
min={5}
|
||
max={1440}
|
||
required
|
||
value={values.search_interval}
|
||
onChange={(event) => set('search_interval', event.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
<p className="mt-1 text-xs text-ink-400">
|
||
Wie oft der Hintergrund-Scanner die Jobbörsen durchsucht.
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className={labelClass}>Anzeige ab %</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={100}
|
||
value={values.display_threshold}
|
||
onChange={(event) => set('display_threshold', event.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
<p className="mt-1 text-xs text-ink-400">
|
||
Treffer darunter bleiben im Dashboard ausgeblendet.
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className={labelClass}>Benachrichtigung ab %</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
max={100}
|
||
value={values.match_threshold}
|
||
onChange={(event) => set('match_threshold', event.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
<p className="mt-1 text-xs text-ink-400">
|
||
Ab diesem Wert gibt es Signalton und E-Mail.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="Abgleich mit Akquise und Opportunities"
|
||
description="Jedes neu gefundene Projekt mit Treffer wird mit den Einträgen der Akquise-Liste und den Opportunities verglichen. Ist es dort schon erfasst – auch unter anderem Titel oder von einer anderen Börse –, erscheint es im Dashboard mit „bereits beworben!“. Die KI (gpt-5.6-luna) nutzt den OpenAI-Key unten."
|
||
accent={appliedCheckActive ? 'border-l-brand-500' : 'border-l-ink-300'}
|
||
>
|
||
<Checkbox
|
||
checked={appliedCheckActive}
|
||
onChange={(checked) => setFlag('applied_check_active', checked)}
|
||
label="Abgleich aktiviert"
|
||
/>
|
||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||
<div className="md:col-span-2">
|
||
<label className={labelClass}>Zeitraum zurück</label>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={periodUnit === 'weeks' ? 52 : 365}
|
||
required
|
||
disabled={!appliedCheckActive}
|
||
value={periodValue}
|
||
onChange={(event) => {
|
||
const next = Math.max(1, Number.parseInt(event.target.value, 10) || 1);
|
||
set('applied_check_days', String(Math.min(365, next * periodFactor)));
|
||
}}
|
||
className={`${inputClass} w-28`}
|
||
/>
|
||
<select
|
||
value={periodUnit}
|
||
disabled={!appliedCheckActive}
|
||
onChange={(event) => {
|
||
const unit = event.target.value as 'days' | 'weeks';
|
||
setPeriodUnit(unit);
|
||
// Der Zeitraum bleibt: "2 Wochen" wird zu "14 Tage". Nach Wochen
|
||
// gerundet wird nur, wenn die Tage nicht aufgehen.
|
||
if (unit === 'weeks') {
|
||
set('applied_check_days', String(Math.min(364, Math.max(1, Math.round(periodDays / 7)) * 7)));
|
||
}
|
||
}}
|
||
className={`${inputClass} w-36`}
|
||
>
|
||
<option value="days">Tage</option>
|
||
<option value="weeks">Wochen</option>
|
||
</select>
|
||
</div>
|
||
<p className="mt-1 text-xs text-ink-400">
|
||
Verglichen wird mit Akquise-Einträgen und Opportunities der letzten {periodDays}{' '}
|
||
{periodDays === 1 ? 'Tag' : 'Tage'} (Papierkorb ausgenommen).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="OpenAI"
|
||
description="Wird für die semantische Bewertung des Lebenslaufs gegen die Projektbeschreibung gebraucht (gpt-4o-mini)."
|
||
>
|
||
<label className={labelClass}>
|
||
API-Key
|
||
{keyStored && (
|
||
<span className="ml-2 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
|
||
gespeichert
|
||
</span>
|
||
)}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={keyStored ? '' : values.openai_api_key}
|
||
onChange={(event) => set('openai_api_key', event.target.value)}
|
||
placeholder={
|
||
keyStored
|
||
? 'Leer lassen, um den gespeicherten Key zu behalten'
|
||
: 'sk-proj-...'
|
||
}
|
||
className={inputClass}
|
||
/>
|
||
</Section>
|
||
|
||
<Section
|
||
title="Browser-Benachrichtigungen"
|
||
description={`Bei einem neuen Treffer über ${values.match_threshold}% spielt das Scanner-Dashboard einen Signalton. Voraussetzung: Der Reiter ist im Browser geöffnet. Browser lassen Töne erst nach dem ersten Klick auf der Seite zu.`}
|
||
>
|
||
<Checkbox
|
||
checked={values.sound_notifications_active === 'true'}
|
||
onChange={(checked) => setFlag('sound_notifications_active', checked)}
|
||
label="Signalton bei neuen Matches"
|
||
/>
|
||
</Section>
|
||
|
||
<Section title="E-Mail-Benachrichtigungen">
|
||
<Checkbox
|
||
checked={emailActive}
|
||
onChange={(checked) => setFlag('email_notifications_active', checked)}
|
||
label="E-Mail bei neuen Matches versenden"
|
||
/>
|
||
|
||
{emailActive && (
|
||
<div className="mt-4 space-y-4 border-l-2 border-brand-200 pl-5">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className={labelClass}>Empfänger (Standard)</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={values.notification_email_recipient}
|
||
onChange={(event) => set('notification_email_recipient', event.target.value)}
|
||
placeholder="info@perinco.de"
|
||
className={inputClass}
|
||
/>
|
||
<p className="mt-1 text-xs text-ink-400">
|
||
Wird verwendet, wenn beim Agenten keine eigene E-Mail hinterlegt ist.
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label className={labelClass}>Absender (From)</label>
|
||
<input
|
||
value={values.smtp_from}
|
||
onChange={(event) => set('smtp_from', event.target.value)}
|
||
placeholder="scanner@perinco.de"
|
||
className={inputClass}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-[2fr_1fr]">
|
||
<div>
|
||
<label className={labelClass}>SMTP-Host</label>
|
||
<input
|
||
required
|
||
value={values.smtp_host}
|
||
onChange={(event) => set('smtp_host', event.target.value)}
|
||
placeholder="smtp.deine-domain.de"
|
||
className={inputClass}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={labelClass}>SMTP-Port</label>
|
||
<input
|
||
type="number"
|
||
required
|
||
value={values.smtp_port}
|
||
onChange={(event) => set('smtp_port', event.target.value)}
|
||
placeholder="587"
|
||
className={inputClass}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<label className={labelClass}>SMTP-Benutzer</label>
|
||
<input
|
||
required
|
||
value={values.smtp_user}
|
||
onChange={(event) => set('smtp_user', event.target.value)}
|
||
className={inputClass}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className={labelClass}>
|
||
SMTP-Passwort
|
||
{values.smtp_pass === SECRET_MASK && (
|
||
<span className="ml-2 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
|
||
gespeichert
|
||
</span>
|
||
)}
|
||
</label>
|
||
<input
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={values.smtp_pass === SECRET_MASK ? '' : values.smtp_pass}
|
||
onChange={(event) => set('smtp_pass', event.target.value)}
|
||
placeholder={
|
||
values.smtp_pass === SECRET_MASK
|
||
? 'Leer lassen, um das gespeicherte Passwort zu behalten'
|
||
: 'Passwort'
|
||
}
|
||
className={inputClass}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Section>
|
||
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="submit"
|
||
disabled={isSaving}
|
||
className="rounded-xl bg-brand-500 px-6 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-60"
|
||
>
|
||
{isSaving ? 'Speichere...' : 'Einstellungen speichern'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
);
|
||
};
|