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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user