import React from 'react'; import type { ScannerMatchStatus, ScannerSourceId } from '../../lib/api'; /* Gemeinsame Beschriftungen und Bausteine von Dashboard und Suchagenten. */ export const SOURCE_LABELS: Record = { 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 = { neu: 'Neu', gesehen: 'Gesehen', in_bearbeitung: 'In Bearbeitung', verworfen: 'Verworfen', }; export const STATUS_TONES: Record = { 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 }) => ( {STATUS_LABELS[status]} ); /** 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 ( {score}% ); }; /* * 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' }) => ( ); /** * 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 (
{value.map((term) => ( {tone === 'alarm' && } {term} ))} { 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" />
); };