feat(17-03): Administrationsseite schrumpft auf das, was Administration ist
- settings/page.tsx zeigt nur noch Abrufintervall + plattformweite Feeds; Postfach- und Benachrichtigungsabschnitt entfernt (ziehen auf my-sources um) - Anzeigepruefung der Rolle aus dem Anmelde-Speicher: unbekannt -> Platzhalter, ADMIN/SUPER_ADMIN -> Inhalt, sonst Hinweistext + Verweis auf "Meine Quellen" (verbindliche Pruefung bleibt serverseitig, T-17-08/@UseModule, siehe Dateikommentar) - Zahnrad auf der Modulseite fuehrt jetzt nach /my-sources statt /settings; neuer Schluessel page.mySourcesTitle, alter page.settingsTitle bleibt als Verweistext auf der Nutzerseite (Task 1) in Gebrauch - settings.emailSectionTitle/emailSectionBody/notificationsSectionTitle aus beiden Sprachdateien entfernt (gegengeprueft: nirgends mehr referenziert); neue Schluessel settings.accessDeniedText, settings.rssSectionUserNote
This commit is contained in:
@@ -68,17 +68,23 @@ function TenderRadarContent() {
|
||||
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/*
|
||||
* Einstellungen-Link. Absolute literal href (NOT the dynamic
|
||||
* [category]/[moduleSlug] basePath): the tender-radar settings page
|
||||
* only exists as the literal route /modules/tender-radar/settings —
|
||||
* the dynamic settings route returns "Modul nicht gefunden" for this
|
||||
* module, so the gear must point at the literal path directly.
|
||||
* Zahnrad-Link. Absolute literal href (NOT the dynamic
|
||||
* [category]/[moduleSlug] basePath): tender-radar routes only
|
||||
* exist as literal paths — the dynamic settings route returns
|
||||
* "Modul nicht gefunden" for this module, so the gear must point
|
||||
* at the literal path directly.
|
||||
*
|
||||
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
|
||||
* admin-only settings page — that's the page every module user
|
||||
* (not just administrators) is allowed to configure something
|
||||
* on; administrators reach the platform-wide settings from there
|
||||
* with one more click.
|
||||
*/}
|
||||
<Link
|
||||
href="/modules/tender-radar/settings"
|
||||
href="/modules/tender-radar/my-sources"
|
||||
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t('page.settingsTitle')}
|
||||
aria-label={t('page.settingsTitle')}
|
||||
title={t('page.mySourcesTitle')}
|
||||
aria-label={t('page.mySourcesTitle')}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
|
||||
@@ -1,89 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
fetchNotificationPref,
|
||||
saveNotificationPref,
|
||||
type NotificationPref,
|
||||
} from '@/lib/tender-radar-api';
|
||||
import { EmailAlertConfigForm } from './components/EmailAlertConfigForm';
|
||||
import Link from 'next/link';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { RssFeedListForm } from './components/RssFeedListForm';
|
||||
import { SourceConfigForm } from './components/SourceConfigForm';
|
||||
|
||||
/**
|
||||
* Ausschreibungs-Radar module settings page.
|
||||
* Ausschreibungs-Radar module settings page — ADMINISTRATION ONLY (Phase
|
||||
* 17, Plan 03, D-03).
|
||||
*
|
||||
* Delivers the admin-facing configuration UI for INGEST-06 ("Intervall pro
|
||||
* Quelle im Admin-Bereich konfigurierbar") — the frontend half of the
|
||||
* requirement, driving the Plan 05 GET/PUT /modules/tender-radar/source-config
|
||||
* endpoint via SourceConfigForm.
|
||||
* Through Plan 02 this page carried four sections spanning three different
|
||||
* responsibilities: platform administration (poll interval, RSS feeds),
|
||||
* tenant/user administration (mailbox), and a purely personal setting
|
||||
* (digest interval) that had no business being here at all. Plan 01/02
|
||||
* moved the mailbox and RSS feeds to per-user ownership; this plan finishes
|
||||
* the split by moving what actually needs a person, not an admin — mailbox,
|
||||
* digest interval — onto `/modules/tender-radar/my-sources`, leaving only
|
||||
* what stays genuinely platform-wide:
|
||||
*
|
||||
* 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.
|
||||
* - `SourceConfigForm` (D-03): the DÖE poll interval. There is exactly one
|
||||
* public source, polling it per-user would just fetch the same data
|
||||
* multiple times.
|
||||
* - `RssFeedListForm scope="platform"` (D-02): admin-managed feeds that
|
||||
* apply to every user, e.g. the service.bund.de default seeded since
|
||||
* Phase 14. Personal feeds live on "Meine Quellen" instead and never
|
||||
* appear here — the server only returns platform-wide feeds for this
|
||||
* scope.
|
||||
*
|
||||
* Plan 14-02 (INGEST-04, D-08/D-09/D-14) adds an "RSS-Feeds" section below
|
||||
* SourceConfigForm: RssFeedListForm, the admin CRUD list for the GLOBAL
|
||||
* (not per-tenant) RSS feed sources. Extends this existing settings page
|
||||
* rather than building a new one (D-09).
|
||||
* Role check (the original backlog trigger, `2026-08-11-tender-radar-
|
||||
* einstellungen-mischen-rollen.md`, open point 5): this check is DISPLAY
|
||||
* ONLY. It hides the two admin sections from a normal user so they never
|
||||
* see a save button wired to something they can't do. The binding check
|
||||
* lives on the server regardless of what this page renders: `source-config`
|
||||
* and `rss-feeds` are `@UseModule('tender-radar')`-gated (Plan 17-02), and
|
||||
* `POST /rss-feeds` with `scope: 'platform'` re-checks ADMIN/SUPER_ADMIN
|
||||
* inline on the server (T-17-08). A manipulated frontend gains nothing.
|
||||
* While the role is still unknown (`user === null`), a loading placeholder
|
||||
* is shown instead of either state, so the admin sections never flash
|
||||
* briefly for a normal user (T-17-16).
|
||||
*
|
||||
* Plan 14-03 (INGEST-05, D-06/D-07/D-09/D-13) adds an "E-Mail-Alerts"
|
||||
* section: EmailAlertConfigForm, the PER-TENANT portal-alert mailbox
|
||||
* config (separate from RSS's global feed list and DKV's own, separate
|
||||
* invoice mailbox, D-03). Same "extend, don't rebuild" stance as RSS-Feeds.
|
||||
*
|
||||
* 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).
|
||||
* No module-loader whitelist change needed here — nested route under the
|
||||
* already-whitelisted `tender-radar` module page (Plan 10-02).
|
||||
*
|
||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||
* tenderRadar i18n namespace.
|
||||
*/
|
||||
export default function TenderRadarSettingsPage() {
|
||||
const t = useTranslations('tenderRadar');
|
||||
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);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationPref()
|
||||
.then((pref) => setDigestInterval(pref.digestInterval))
|
||||
.catch((err) => {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t('settings.errorLoad'),
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (user === null) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<div className="mb-6 h-8 w-64 rounded bg-muted animate-pulse" />
|
||||
<div className="h-40 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 : t('settings.errorSave'),
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-4">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
{t('settings.accessDeniedText')}
|
||||
</p>
|
||||
<Link
|
||||
href="/modules/tender-radar/my-sources"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('mySources.title')} →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
@@ -96,60 +90,13 @@ export default function TenderRadarSettingsPage() {
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{t('settings.rssSectionTitle')}
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
{t('settings.rssSectionBody')}
|
||||
</p>
|
||||
<RssFeedListForm scope="platform" />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{t('settings.emailSectionTitle')}
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
{t('settings.emailSectionBody')}
|
||||
{t('settings.rssSectionUserNote')}
|
||||
</p>
|
||||
<EmailAlertConfigForm />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{t('settings.notificationsSectionTitle')}
|
||||
</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"
|
||||
>
|
||||
{t('settings.digestIntervalLabel')}
|
||||
</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('settings.digestDaily')}</option>
|
||||
<option value="weekly">{t('settings.digestWeekly')}</option>
|
||||
<option value="off">{t('settings.digestOff')}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('settings.digestHelp')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveSuccess && (
|
||||
<p className="mt-2 text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
|
||||
{t('settings.saveSuccess')}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
<RssFeedListForm scope="platform" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user