Files
ICON-CRM/src/components/scanner/scannerUi.tsx
T
schakal00783andClaude Opus 5.5 651ad2b954 Scanner: neue Anzeigen zuerst abfragen, Uhrzeiten korrekt anzeigen
Seit dem Wegfall der Altersgrenze lieferten BA und StepStone nur die
relevantesten Anzeigen aller Jahre - fünf Läufe lang 0 neue Treffer.
- BA: erst veroeffentlichtseit=7 (bis 200), dann ohne Grenze (bis 200)
- StepStone: sort=2 Seiten 1-2 plus Relevanz Seite 1
  (action=sort_publish hatte keine Wirkung)
- Dashboard liest DB-Zeitstempel als UTC (vorher 2 h zu früh)

Erster Lauf danach: 481 statt 298 Anzeigen, 40 neue Treffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:28:04 +02:00

200 lines
7.1 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 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',
freelancermap: 'freelancermap',
};
export const SOURCE_ORDER: ScannerSourceId[] = ['ba', 'stepstone', 'karriere', 'adzuna', '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>
);
};
/*
* Zeitstempel aus der Datenbank ("2026-09-29 11:18:31") sind UTC - Server und
* MariaDB laufen in UTC. Ohne das "Z" las der Browser sie als Ortszeit und
* zeigte Funde zwei Stunden zu früh an (13:18 erschien als 11:18).
*/
const parseDbDate = (value: string) =>
new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? value : `${value.replace(' ', 'T')}Z`);
export const formatDateTime = (value: string | null) => {
if (!value) return '–';
const date = parseDbDate(value);
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 = parseDbDate(value);
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'];
const BELL_ICON =
'M12 22a2.5 2.5 0 0 0 2.5-2.5h-5A2.5 2.5 0 0 0 12 22Zm7-5V11a7 7 0 0 0-5.5-6.8V3a1.5 1.5 0 0 0-3 0v1.2A7 7 0 0 0 5 11v6l-2 2v1h18v-1l-2-2Z';
/** Glocke für Treffer mit Alarm-Schlagwort. */
export const AlarmBell: React.FC<{ className?: string }> = ({ className = 'h-4 w-4' }) => (
<span title="Alarm-Schlagwort" className="inline-flex text-red-600">
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden="true">
<path d={BELL_ICON} />
</svg>
</span>
);
/**
* Kurzer Zweiklang über WebAudio - ohne Audiodatei.
*
* Browser spielen Ton erst, nachdem auf der Seite einmal geklickt oder getippt
* wurde (Autoplay-Regel). Klappt es nicht, bleibt es still; das Pop-up kommt
* trotzdem.
*/
export function playAlarmSound() {
try {
const AudioContextClass =
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
const context = new AudioContextClass();
const tone = (frequency: number, start: number) => {
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = frequency;
gain.gain.setValueAtTime(0.0001, context.currentTime + start);
gain.gain.exponentialRampToValueAtTime(0.3, context.currentTime + start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + start + 0.35);
oscillator.connect(gain).connect(context.destination);
oscillator.start(context.currentTime + start);
oscillator.stop(context.currentTime + start + 0.4);
};
tone(880, 0);
tone(1175, 0.18);
// Nicht geschlossene Kontexte zählen gegen das Browserlimit.
setTimeout(() => void context.close().catch(() => {}), 1000);
} catch {
/* kein Ton möglich - das Pop-up reicht */
}
}
/** 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' | 'alarm';
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
const [draft, setDraft] = React.useState('');
const add = (text: string) => {
// Anführungszeichen gehören nicht zum Begriff ("Interim" -> Interim).
const terms = text
.split(',')
.map((term) => term.trim().replace(/^["'„“”‚‘’«»]+|["'„“”‚‘’«»]+$/g, '').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'
: tone === 'alarm'
? 'border-amber-300 bg-amber-50 text-amber-800'
: '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}`}>
{tone === 'alarm' && <AlarmBell className="h-3 w-3" />}
{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>
);
};