Files
ICON-CRM/src/components/settings/ScannerSettings.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

184 lines
7.0 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 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>
);