Scanner: Stellenanzeigen aus Jobbörsen (DACH, max. 21 Tage)

Neuer Menüpunkt Scanner mit Dashboard und Suchagenten, Zeitplan und
API-Schlüssel unter Einstellungen -> Scanner.

- Quellen: Agentur für Arbeit (API), StepStone, karriere.at,
  freelancermap (HTML) sowie Adzuna und Jooble (kostenlose Schlüssel)
- Quote = gefundene Schlagwörter, Titel zählt doppelt; keine KI
- Abgleich mit icon_companies, bekannte Firmen oben mit Abzeichen
- Zeitplan Vorgabe stündlich Mo-Fr 06-20 Uhr, einstellbar
- Sperre gegen parallele Läufe wie im perinco-Scanner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 10:49:44 +02:00
co-authored by Claude Opus 5.5
parent 6fe5b18c11
commit 1b82f580db
23 changed files with 2894 additions and 2 deletions
+140
View File
@@ -0,0 +1,140 @@
import React from 'react';
import type { ScannerMatchStatus, ScannerSourceId } from '../../lib/api';
/* Gemeinsame Beschriftungen und Bausteine von Dashboard und Suchagenten. */
export const SOURCE_LABELS: Record<ScannerSourceId, string> = {
ba: 'Agentur für Arbeit',
stepstone: 'StepStone',
karriere: 'karriere.at',
adzuna: 'Adzuna',
jooble: 'Jooble',
freelancermap: 'freelancermap',
};
export const SOURCE_ORDER: ScannerSourceId[] = ['ba', 'stepstone', 'karriere', 'adzuna', 'jooble', 'freelancermap'];
export const STATUS_LABELS: Record<ScannerMatchStatus, string> = {
neu: 'Neu',
gesehen: 'Gesehen',
in_bearbeitung: 'In Bearbeitung',
verworfen: 'Verworfen',
};
export const STATUS_TONES: Record<ScannerMatchStatus, string> = {
neu: 'border-sky-200 bg-sky-50 text-sky-700',
gesehen: 'border-amber-200 bg-amber-50 text-amber-700',
in_bearbeitung: 'border-emerald-200 bg-emerald-50 text-emerald-700',
verworfen: 'border-ink-200 bg-ink-50 text-ink-500',
};
export const StatusBadge: React.FC<{ status: ScannerMatchStatus }> = ({ status }) => (
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wide ${STATUS_TONES[status]}`}
>
{STATUS_LABELS[status]}
</span>
);
/** Quadrat mit der Trefferquote - Farbe nach Güte. */
export const ScoreBox: React.FC<{ score: number }> = ({ score }) => {
const tone =
score >= 75
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: score >= 50
? 'border-amber-500 bg-amber-50 text-amber-700'
: 'border-ink-300 bg-ink-50 text-ink-600';
return (
<span
className={`inline-flex h-9 w-11 items-center justify-center rounded-md border-2 text-xs font-bold tabular-nums ${tone}`}
>
{score}%
</span>
);
};
export const formatDateTime = (value: string | null) => {
if (!value) return '–';
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
if (Number.isNaN(date.getTime())) return '–';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
/** "vor 3 Tagen" - fürs Alter einer Anzeige genauer als ein Datum. */
export const formatAge = (value: string | null) => {
if (!value) return 'unbekannt';
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
const days = Math.floor((Date.now() - date.getTime()) / 86_400_000);
if (Number.isNaN(days)) return 'unbekannt';
if (days <= 0) return 'heute';
if (days === 1) return 'gestern';
return `vor ${days} Tagen`;
};
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
/** Eingabe von Begriffen als Chips: Enter oder Komma fügt hinzu. */
export const TermInput: React.FC<{
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
tone?: 'brand' | 'red';
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
const [draft, setDraft] = React.useState('');
const add = (text: string) => {
const terms = text
.split(',')
.map((term) => term.trim())
.filter(Boolean)
.filter((term) => !value.some((existing) => existing.toLowerCase() === term.toLowerCase()));
if (terms.length) onChange([...value, ...terms]);
setDraft('');
};
const chip =
tone === 'red' ? 'border-red-200 bg-red-50 text-red-700' : 'border-brand-200 bg-brand-50 text-brand-700';
return (
<div className="flex min-h-[42px] flex-wrap items-center gap-1.5 rounded-lg border border-ink-200 bg-surface px-2 py-1.5">
{value.map((term) => (
<span key={term} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${chip}`}>
{term}
<button
type="button"
onClick={() => onChange(value.filter((item) => item !== term))}
className="opacity-60 hover:opacity-100"
aria-label={`${term} entfernen`}
>
×
</button>
</span>
))}
<input
value={draft}
onChange={(event) => {
const text = event.target.value;
if (text.includes(',')) add(text);
else setDraft(text);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
add(draft);
} else if (event.key === 'Backspace' && !draft && value.length) {
onChange(value.slice(0, -1));
}
}}
onBlur={() => draft.trim() && add(draft)}
placeholder={value.length ? '' : placeholder}
className="min-w-[140px] flex-1 border-0 bg-transparent px-1 py-0.5 text-sm text-ink-900 placeholder:text-ink-400 focus:outline-none focus:ring-0"
/>
</div>
);
};