- 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>
111 lines
3.7 KiB
TypeScript
111 lines
3.7 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { scannerApi, type ScannerAlarm } from '../../lib/api';
|
|
import { useNavigation } from '../../lib/navigation';
|
|
import { Modal } from '../ui/Modal';
|
|
import { PrimaryButton, SecondaryButton } from '../ui/form';
|
|
import { useToast } from '../Toast';
|
|
import { AlarmBell, SOURCE_LABELS, formatAge, playAlarmSound } from './scannerUi';
|
|
|
|
/*
|
|
* Alarm des Scanners: Treffer mit Alarm-Schlagwort melden sich mit Ton und
|
|
* Pop-up - egal, welche Ansicht offen ist (deshalb hängt der Wächter in App,
|
|
* nicht im Scanner-Dashboard; beim perinco-Scanner hörte man den Ton nur dort).
|
|
*
|
|
* Abgefragt wird jede Minute. Der Scanner läuft stündlich, ein Fund meldet
|
|
* sich also spätestens eine Minute nach dem Lauf. Quittieren gilt für alle
|
|
* Benutzer (serverseitig `alarm_ack`).
|
|
*/
|
|
|
|
const POLL_MS = 60_000;
|
|
|
|
export const AlarmWatcher: React.FC = () => {
|
|
const { toast } = useToast();
|
|
const { setView } = useNavigation();
|
|
const [alarms, setAlarms] = useState<ScannerAlarm[]>([]);
|
|
/** Welche Alarme schon geklingelt haben - neue klingeln erneut. */
|
|
const rung = useRef(new Set<number>());
|
|
|
|
const check = useCallback(async () => {
|
|
try {
|
|
const { alarms: list } = await scannerApi.alarms();
|
|
setAlarms(list);
|
|
if (list.some((alarm) => !rung.current.has(alarm.id))) {
|
|
list.forEach((alarm) => rung.current.add(alarm.id));
|
|
playAlarmSound();
|
|
}
|
|
} catch {
|
|
/* kein Netz oder Sitzung abgelaufen - beim nächsten Mal */
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void check();
|
|
const timer = setInterval(() => void check(), POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [check]);
|
|
|
|
if (alarms.length === 0) return null;
|
|
|
|
const acknowledge = async (goToScanner = false) => {
|
|
const ids = alarms.map((alarm) => alarm.id);
|
|
setAlarms([]);
|
|
try {
|
|
await scannerApi.ackAlarms(ids);
|
|
} catch (err) {
|
|
toast(err instanceof Error ? err.message : 'Quittieren fehlgeschlagen.', 'error');
|
|
}
|
|
if (goToScanner) setView('Scanner');
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open
|
|
size="md"
|
|
onClose={() => void acknowledge()}
|
|
title={
|
|
<span className="flex items-center gap-2">
|
|
<AlarmBell className="h-5 w-5" />
|
|
{alarms.length === 1 ? 'Alarm-Treffer im Scanner' : `${alarms.length} Alarm-Treffer im Scanner`}
|
|
</span>
|
|
}
|
|
footer={
|
|
<>
|
|
<SecondaryButton type="button" onClick={() => void acknowledge()}>
|
|
Gesehen
|
|
</SecondaryButton>
|
|
<PrimaryButton type="button" onClick={() => void acknowledge(true)}>
|
|
Zum Scanner
|
|
</PrimaryButton>
|
|
</>
|
|
}
|
|
>
|
|
<ul className="max-h-[50vh] space-y-2 overflow-y-auto">
|
|
{alarms.map((alarm) => (
|
|
<li key={alarm.id} className="rounded-lg border border-red-200 bg-red-50/50 p-3">
|
|
<a
|
|
href={alarm.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="font-semibold text-ink-900 hover:text-brand-700"
|
|
>
|
|
{alarm.title} ↗
|
|
</a>
|
|
<p className="mt-0.5 text-xs text-ink-500">
|
|
{[alarm.company, alarm.location, SOURCE_LABELS[alarm.source]].filter(Boolean).join(' · ')} ·{' '}
|
|
{formatAge(alarm.published_at)}
|
|
</p>
|
|
<p className="mt-1 text-xs text-ink-600">
|
|
Agent <strong>{alarm.agent_name}</strong> · gefunden:{' '}
|
|
{alarm.hits.map((hit) => (
|
|
<span key={hit} className="mr-1 rounded-full border border-red-200 bg-surface px-1.5 py-0.5 text-red-700">
|
|
{hit}
|
|
</span>
|
|
))}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Modal>
|
|
);
|
|
};
|