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:
co-authored by
Claude Opus 5.5
parent
6fe5b18c11
commit
1b82f580db
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user