feat(12-04): tender-radar digest-interval selector + Sofort-Alert toggle UI

- Settings page: Benachrichtigungen section with Täglich/Wöchentlich/Aus
  selector, loads via fetchNotificationPref, saves via saveNotificationPref
  (NOTIFY-01)
- SavedSearchBar: per-profile Sofort-Alert checkbox reflecting
  instantAlert, calls updateSavedSearch({ instantAlert }) + reloads
  (NOTIFY-02, D-04)
- SavedSearchBar.test.tsx: checkbox state + toggle-calls-updateSavedSearch
  coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 09:30:54 +02:00
parent 73a7e49f85
commit d60080ef20
3 changed files with 193 additions and 4 deletions
@@ -49,7 +49,14 @@ afterEach(() => {
describe('SavedSearchBar', () => {
it('fetches on mount and renders each saved profile by name', async () => {
mockListSavedSearches.mockResolvedValue([
{ id: 'ss-1', name: 'Bau NRW', filters: { q: 'Bau' }, createdAt: '', updatedAt: '' },
{
id: 'ss-1',
name: 'Bau NRW',
filters: { q: 'Bau' },
instantAlert: false,
createdAt: '',
updatedAt: '',
},
]);
const { SavedSearchBar } = await import('./SavedSearchBar');
@@ -81,6 +88,7 @@ describe('SavedSearchBar', () => {
id: 'ss-new',
name: 'Mein Profil',
filters: {},
instantAlert: false,
createdAt: '',
updatedAt: '',
});
@@ -136,6 +144,7 @@ describe('SavedSearchBar', () => {
id: 'ss-1',
name: 'Bau NRW',
filters: { q: 'Bau', cpv: ['45', '71'], openOnly: 'false' },
instantAlert: false,
createdAt: '',
updatedAt: '',
},
@@ -158,15 +167,16 @@ describe('SavedSearchBar', () => {
it('renaming a profile calls updateSavedSearch(id, { name }) and reloads the list', async () => {
mockListSavedSearches
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Alt', filters: {}, createdAt: '', updatedAt: '' },
{ id: 'ss-1', name: 'Alt', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
])
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Neu', filters: {}, createdAt: '', updatedAt: '' },
{ id: 'ss-1', name: 'Neu', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
]);
mockUpdateSavedSearch.mockResolvedValue({
id: 'ss-1',
name: 'Neu',
filters: {},
instantAlert: false,
createdAt: '',
updatedAt: '',
});
@@ -192,7 +202,7 @@ describe('SavedSearchBar', () => {
it('deleting a profile calls deleteSavedSearch(id) and reloads the list', async () => {
mockListSavedSearches
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Weg damit', filters: {}, createdAt: '', updatedAt: '' },
{ id: 'ss-1', name: 'Weg damit', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
])
.mockResolvedValueOnce([]);
mockDeleteSavedSearch.mockResolvedValue(undefined);
@@ -208,4 +218,51 @@ describe('SavedSearchBar', () => {
expect(screen.getByText(/Noch keine Suchprofile gespeichert/i)).toBeInTheDocument();
});
});
// --- Sofort-Alert toggle (Plan 12-04, NOTIFY-02, D-04) --------------------
it('renders the Sofort-Alert checkbox reflecting profile.instantAlert', async () => {
mockListSavedSearches.mockResolvedValue([
{ id: 'ss-1', name: 'Bau NRW', filters: {}, instantAlert: true, createdAt: '', updatedAt: '' },
{ id: 'ss-2', name: 'Straßenbau', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
]);
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
const onCheckbox = await screen.findByLabelText('Sofort-Alert für Bau NRW');
const offCheckbox = await screen.findByLabelText('Sofort-Alert für Straßenbau');
expect(onCheckbox).toBeChecked();
expect(offCheckbox).not.toBeChecked();
});
it('clicking the Sofort-Alert checkbox calls updateSavedSearch(id, { instantAlert: <negated> }) and reloads the list', async () => {
mockListSavedSearches
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Bau NRW', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
])
.mockResolvedValueOnce([
{ id: 'ss-1', name: 'Bau NRW', filters: {}, instantAlert: true, createdAt: '', updatedAt: '' },
]);
mockUpdateSavedSearch.mockResolvedValue({
id: 'ss-1',
name: 'Bau NRW',
filters: {},
instantAlert: true,
createdAt: '',
updatedAt: '',
});
const { SavedSearchBar } = await import('./SavedSearchBar');
render(<SavedSearchBar />);
const checkbox = await screen.findByLabelText('Sofort-Alert für Bau NRW');
fireEvent.click(checkbox);
await waitFor(() => {
expect(mockUpdateSavedSearch).toHaveBeenCalledWith('ss-1', { instantAlert: true });
});
await waitFor(() => expect(mockListSavedSearches).toHaveBeenCalledTimes(2));
});
});
@@ -30,6 +30,12 @@ import {
* Per-user, NOT tenant-wide (D-11): the backend derives userId from the
* auth cookie; this component never sends a userId.
*
* Plan 12-04 (NOTIFY-02, D-04) adds a Sofort-Alert toggle per profile chip:
* a checkbox reflecting `profile.instantAlert` (default false) that calls
* updateSavedSearch(profile.id, { instantAlert: next }) on change. This is
* a profile field alongside name/filters, not a filter key — it never
* touches the URL searchParams filters contract above.
*
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
* Question 3, RESOLVED), same convention as FilterPanel/ResultsList.
*/
@@ -200,6 +206,20 @@ export function SavedSearchBar() {
}
};
const handleToggleInstantAlert = async (profile: SavedSearch) => {
setError(null);
try {
await updateSavedSearch(profile.id, { instantAlert: !profile.instantAlert });
await load();
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Sofort-Alert konnte nicht umgeschaltet werden.',
);
}
};
return (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3">
<h2 className="text-sm font-semibold text-foreground">Suchprofile</h2>
@@ -281,6 +301,19 @@ export function SavedSearchBar() {
>
{profile.name}
</button>
<label
className="flex items-center gap-1 text-xs text-muted-foreground"
title="Sofort-Alert per E-Mail bei neuen Treffern dieses Profils"
>
<input
type="checkbox"
aria-label={`Sofort-Alert für ${profile.name}`}
checked={profile.instantAlert}
onChange={() => handleToggleInstantAlert(profile)}
className="h-3.5 w-3.5"
/>
Sofort-Alert
</label>
<button
type="button"
aria-label={`${profile.name} umbenennen`}
@@ -1,5 +1,11 @@
'use client';
import { useEffect, useState } from 'react';
import {
fetchNotificationPref,
saveNotificationPref,
type NotificationPref,
} from '@/lib/tender-radar-api';
import { SourceConfigForm } from './components/SourceConfigForm';
/**
@@ -10,6 +16,14 @@ import { SourceConfigForm } from './components/SourceConfigForm';
* requirement, driving the Plan 05 GET/PUT /modules/tender-radar/source-config
* endpoint via SourceConfigForm.
*
* Plan 12-04 (NOTIFY-01, D-01/D-03) adds a "Benachrichtigungen" section
* below: a Täglich/Wöchentlich/Aus selector for this user's digest interval
* preference, loaded via fetchNotificationPref on mount and saved via
* saveNotificationPref on change. Kept inline here (the page is already a
* 'use client' shell, unlike the admin-only SourceConfigForm split) rather
* than split into a separate component — this is a single select, no
* standalone unit-test coverage was called for in this plan.
*
* No module-loader whitelist change is needed here: this is a standard
* Next.js App Router route nested under the already-whitelisted
* `tender-radar` module page (Plan 10-02).
@@ -19,12 +33,97 @@ import { SourceConfigForm } from './components/SourceConfigForm';
* used by the tender-radar placeholder page.
*/
export default function TenderRadarSettingsPage() {
const [digestInterval, setDigestInterval] = useState<
NotificationPref['digestInterval']
>('daily');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
fetchNotificationPref()
.then((pref) => setDigestInterval(pref.digestInterval))
.catch((err) => {
setError(
err instanceof Error
? err.message
: 'Benachrichtigungseinstellung konnte nicht geladen werden',
);
})
.finally(() => setIsLoading(false));
}, []);
const handleChange = async (value: string) => {
const next = value as NotificationPref['digestInterval'];
setDigestInterval(next);
setError(null);
setSaveSuccess(false);
setIsSaving(true);
try {
const result = await saveNotificationPref(next);
setDigestInterval(result.digestInterval);
setSaveSuccess(true);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Benachrichtigungseinstellung konnte nicht gespeichert werden',
);
} finally {
setIsSaving(false);
}
};
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-semibold tracking-tight mb-6">
Ausschreibungs-Radar — Einstellungen
</h1>
<SourceConfigForm />
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Benachrichtigungen
</h2>
{isLoading ? (
<div className="h-9 max-w-xs rounded bg-muted animate-pulse" />
) : (
<div>
<label
htmlFor="tr-digest-interval"
className="mb-1 block text-sm text-foreground"
>
Digest-Intervall
</label>
<select
id="tr-digest-interval"
value={digestInterval}
onChange={(e) => handleChange(e.target.value)}
disabled={isSaving}
className="h-9 max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="off">Aus</option>
</select>
<p className="mt-1 text-xs text-muted-foreground">
Fasst neue Treffer aus Ihren Suchprofilen in einer täglichen
oder wöchentlichen Sammel-Mail zusammen. „Aus" deaktiviert den
Digest — Sofort-Alerts pro Suchprofil bleiben unabhängig davon
nutzbar.
</p>
</div>
)}
{saveSuccess && (
<p className="mt-2 text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellung gespeichert.
</p>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</div>
</div>
);
}