Scanner: ohne Altersgrenze suchen, Filter Anzeigenalter, Alarm-Schlagwörter
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b34a1968df
commit
1ad774fdd2
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user