- 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>
184 lines
7.0 KiB
TypeScript
184 lines
7.0 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { scannerApi, type ScannerSchedule, type ScannerSecrets } from '../../lib/api';
|
||
import { Field, PrimaryButton, inputClass } from '../ui/form';
|
||
import { useToast } from '../Toast';
|
||
import { WEEKDAY_LABELS } from '../scanner/scannerUi';
|
||
|
||
/*
|
||
* Reiter "Scanner" in den Einstellungen (nur Administratoren).
|
||
*
|
||
* Wann und wie oft der Scanner selbst sucht, und der kostenlose API-Schlüssel
|
||
* für Adzuna. Die
|
||
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
|
||
*/
|
||
|
||
const INTERVALS = [15, 30, 60, 120, 180, 240, 360];
|
||
|
||
export const ScannerSettings: React.FC = () => {
|
||
const { toast } = useToast();
|
||
const [schedule, setSchedule] = useState<ScannerSchedule | null>(null);
|
||
const [secrets, setSecrets] = useState<ScannerSecrets | null>(null);
|
||
const [keys, setKeys] = useState({ adzunaId: '', adzunaKey: '' });
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
scannerApi
|
||
.settings()
|
||
.then((data) => {
|
||
setSchedule(data.settings);
|
||
setSecrets(data.secrets);
|
||
})
|
||
.catch((err) => toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'));
|
||
}, [toast]);
|
||
|
||
const save = async (changes: Parameters<typeof scannerApi.saveSettings>[0]) => {
|
||
setBusy(true);
|
||
try {
|
||
const data = await scannerApi.saveSettings(changes);
|
||
setSchedule(data.settings);
|
||
setSecrets(data.secrets);
|
||
setKeys({ adzunaId: '', adzunaKey: '' });
|
||
toast('Gespeichert.', 'success');
|
||
} catch (err) {
|
||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
if (!schedule || !secrets) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
|
||
|
||
const set = (changes: Partial<ScannerSchedule>) => setSchedule({ ...schedule, ...changes });
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||
<h2 className="text-base font-semibold text-ink-900">Zeitplan</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-ink-500">
|
||
In diesem Fenster sucht der Scanner selbstständig. Außerhalb geht es nur über „Jetzt scannen“ im
|
||
Dashboard. Gerechnet wird in deutscher Zeit.
|
||
</p>
|
||
|
||
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-ink-800">
|
||
<input type="checkbox" checked={schedule.active} onChange={(event) => set({ active: event.target.checked })} />
|
||
Automatische Suche aktiv
|
||
</label>
|
||
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||
<Field label="Wochentage" className="lg:col-span-2">
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{WEEKDAY_LABELS.map((label, index) => {
|
||
const day = index + 1;
|
||
const on = schedule.days.includes(day);
|
||
return (
|
||
<button
|
||
key={label}
|
||
type="button"
|
||
onClick={() =>
|
||
set({ days: on ? schedule.days.filter((d) => d !== day) : [...schedule.days, day].sort() })
|
||
}
|
||
className={`h-9 w-11 rounded-lg border text-sm font-medium ${
|
||
on ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-500 hover:bg-ink-50'
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</Field>
|
||
<Field label="Von">
|
||
<input type="time" value={schedule.from} onChange={(event) => set({ from: event.target.value })} className={inputClass} />
|
||
</Field>
|
||
<Field label="Bis" hint="Ein Lauf um genau diese Uhrzeit zählt noch.">
|
||
<input type="time" value={schedule.to} onChange={(event) => set({ to: event.target.value })} className={inputClass} />
|
||
</Field>
|
||
<Field label="Intervall">
|
||
<select
|
||
value={schedule.interval}
|
||
onChange={(event) => set({ interval: Number(event.target.value) })}
|
||
className={inputClass}
|
||
>
|
||
{Array.from(new Set([...INTERVALS, schedule.interval]))
|
||
.sort((a, b) => a - b)
|
||
.map((minutes) => (
|
||
<option key={minutes} value={minutes}>
|
||
{minutes < 60 ? `alle ${minutes} Minuten` : minutes === 60 ? 'stündlich' : `alle ${minutes / 60} Stunden`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
</div>
|
||
<p className="mt-3 text-xs text-ink-400">
|
||
Gesucht wird ohne Altersgrenze – das Alter der Anzeigen filtert das Dashboard („Anzeigenalter“).
|
||
</p>
|
||
|
||
<PrimaryButton
|
||
type="button"
|
||
className="mt-5"
|
||
disabled={busy || schedule.days.length === 0}
|
||
onClick={() => void save(schedule)}
|
||
>
|
||
Zeitplan speichern
|
||
</PrimaryButton>
|
||
</section>
|
||
|
||
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||
<h2 className="text-base font-semibold text-ink-900">API-Schlüssel</h2>
|
||
<p className="mt-1 max-w-3xl text-sm text-ink-500">
|
||
Agentur für Arbeit, StepStone, karriere.at und freelancermap brauchen nichts. Adzuna (einzige Quelle für
|
||
die Schweiz) gibt nach kostenloser Registrierung einen Schlüssel aus. Ohne Schlüssel wird es
|
||
übersprungen.
|
||
</p>
|
||
|
||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||
<KeyField
|
||
label="Adzuna App ID"
|
||
current={secrets.adzunaId}
|
||
value={keys.adzunaId}
|
||
onChange={(adzunaId) => setKeys({ ...keys, adzunaId })}
|
||
hint="developer.adzuna.com → Register"
|
||
/>
|
||
<KeyField
|
||
label="Adzuna App Key"
|
||
current={secrets.adzunaKey}
|
||
value={keys.adzunaKey}
|
||
onChange={(adzunaKey) => setKeys({ ...keys, adzunaKey })}
|
||
/>
|
||
</div>
|
||
|
||
<PrimaryButton
|
||
type="button"
|
||
className="mt-5"
|
||
disabled={busy || !Object.values(keys).some((value) => value.trim())}
|
||
onClick={() =>
|
||
void save(Object.fromEntries(Object.entries(keys).filter(([, value]) => value.trim())))
|
||
}
|
||
>
|
||
Schlüssel speichern
|
||
</PrimaryButton>
|
||
</section>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const KeyField: React.FC<{
|
||
label: string;
|
||
current: string | null;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
hint?: string;
|
||
}> = ({ label, current, value, onChange, hint }) => (
|
||
<Field label={label} hint={current ? `Hinterlegt: ${current}${hint ? ` · ${hint}` : ''}` : hint ?? 'Nicht hinterlegt.'}>
|
||
<input
|
||
type="password"
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={current ? '•••••••• (ersetzen)' : ''}
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
className={`${inputClass} font-mono`}
|
||
/>
|
||
</Field>
|
||
);
|