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', () => {
|
describe('SavedSearchBar', () => {
|
||||||
it('fetches on mount and renders each saved profile by name', async () => {
|
it('fetches on mount and renders each saved profile by name', async () => {
|
||||||
mockListSavedSearches.mockResolvedValue([
|
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');
|
const { SavedSearchBar } = await import('./SavedSearchBar');
|
||||||
@@ -81,6 +88,7 @@ describe('SavedSearchBar', () => {
|
|||||||
id: 'ss-new',
|
id: 'ss-new',
|
||||||
name: 'Mein Profil',
|
name: 'Mein Profil',
|
||||||
filters: {},
|
filters: {},
|
||||||
|
instantAlert: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
});
|
});
|
||||||
@@ -136,6 +144,7 @@ describe('SavedSearchBar', () => {
|
|||||||
id: 'ss-1',
|
id: 'ss-1',
|
||||||
name: 'Bau NRW',
|
name: 'Bau NRW',
|
||||||
filters: { q: 'Bau', cpv: ['45', '71'], openOnly: 'false' },
|
filters: { q: 'Bau', cpv: ['45', '71'], openOnly: 'false' },
|
||||||
|
instantAlert: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
},
|
},
|
||||||
@@ -158,15 +167,16 @@ describe('SavedSearchBar', () => {
|
|||||||
it('renaming a profile calls updateSavedSearch(id, { name }) and reloads the list', async () => {
|
it('renaming a profile calls updateSavedSearch(id, { name }) and reloads the list', async () => {
|
||||||
mockListSavedSearches
|
mockListSavedSearches
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ id: 'ss-1', name: 'Alt', filters: {}, createdAt: '', updatedAt: '' },
|
{ id: 'ss-1', name: 'Alt', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ id: 'ss-1', name: 'Neu', filters: {}, createdAt: '', updatedAt: '' },
|
{ id: 'ss-1', name: 'Neu', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
|
||||||
]);
|
]);
|
||||||
mockUpdateSavedSearch.mockResolvedValue({
|
mockUpdateSavedSearch.mockResolvedValue({
|
||||||
id: 'ss-1',
|
id: 'ss-1',
|
||||||
name: 'Neu',
|
name: 'Neu',
|
||||||
filters: {},
|
filters: {},
|
||||||
|
instantAlert: false,
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: '',
|
updatedAt: '',
|
||||||
});
|
});
|
||||||
@@ -192,7 +202,7 @@ describe('SavedSearchBar', () => {
|
|||||||
it('deleting a profile calls deleteSavedSearch(id) and reloads the list', async () => {
|
it('deleting a profile calls deleteSavedSearch(id) and reloads the list', async () => {
|
||||||
mockListSavedSearches
|
mockListSavedSearches
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{ id: 'ss-1', name: 'Weg damit', filters: {}, createdAt: '', updatedAt: '' },
|
{ id: 'ss-1', name: 'Weg damit', filters: {}, instantAlert: false, createdAt: '', updatedAt: '' },
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([]);
|
.mockResolvedValueOnce([]);
|
||||||
mockDeleteSavedSearch.mockResolvedValue(undefined);
|
mockDeleteSavedSearch.mockResolvedValue(undefined);
|
||||||
@@ -208,4 +218,51 @@ describe('SavedSearchBar', () => {
|
|||||||
expect(screen.getByText(/Noch keine Suchprofile gespeichert/i)).toBeInTheDocument();
|
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
|
* Per-user, NOT tenant-wide (D-11): the backend derives userId from the
|
||||||
* auth cookie; this component never sends a userId.
|
* 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
|
* Hardcoded German strings — i18n rollout is Phase 14 (Research Open
|
||||||
* Question 3, RESOLVED), same convention as FilterPanel/ResultsList.
|
* 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 (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3">
|
<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>
|
<h2 className="text-sm font-semibold text-foreground">Suchprofile</h2>
|
||||||
@@ -281,6 +301,19 @@ export function SavedSearchBar() {
|
|||||||
>
|
>
|
||||||
{profile.name}
|
{profile.name}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${profile.name} umbenennen`}
|
aria-label={`${profile.name} umbenennen`}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
fetchNotificationPref,
|
||||||
|
saveNotificationPref,
|
||||||
|
type NotificationPref,
|
||||||
|
} from '@/lib/tender-radar-api';
|
||||||
import { SourceConfigForm } from './components/SourceConfigForm';
|
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
|
* requirement, driving the Plan 05 GET/PUT /modules/tender-radar/source-config
|
||||||
* endpoint via SourceConfigForm.
|
* 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
|
* No module-loader whitelist change is needed here: this is a standard
|
||||||
* Next.js App Router route nested under the already-whitelisted
|
* Next.js App Router route nested under the already-whitelisted
|
||||||
* `tender-radar` module page (Plan 10-02).
|
* `tender-radar` module page (Plan 10-02).
|
||||||
@@ -19,12 +33,97 @@ import { SourceConfigForm } from './components/SourceConfigForm';
|
|||||||
* used by the tender-radar placeholder page.
|
* used by the tender-radar placeholder page.
|
||||||
*/
|
*/
|
||||||
export default function TenderRadarSettingsPage() {
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-2xl p-6">
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-6">
|
<h1 className="text-2xl font-semibold tracking-tight mb-6">
|
||||||
Ausschreibungs-Radar — Einstellungen
|
Ausschreibungs-Radar — Einstellungen
|
||||||
</h1>
|
</h1>
|
||||||
<SourceConfigForm />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user