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 */}
|
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/*
|
{/*
|
||||||
* Einstellungen-Link. Absolute literal href (NOT the dynamic
|
* Zahnrad-Link. Absolute literal href (NOT the dynamic
|
||||||
* [category]/[moduleSlug] basePath): the tender-radar settings page
|
* [category]/[moduleSlug] basePath): tender-radar routes only
|
||||||
* only exists as the literal route /modules/tender-radar/settings —
|
* exist as literal paths — the dynamic settings route returns
|
||||||
* the dynamic settings route returns "Modul nicht gefunden" for this
|
* "Modul nicht gefunden" for this module, so the gear must point
|
||||||
* module, so the gear must point at the literal path directly.
|
* 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
|
<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"
|
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')}
|
title={t('page.mySourcesTitle')}
|
||||||
aria-label={t('page.settingsTitle')}
|
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">
|
<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" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
|||||||
@@ -1,89 +1,83 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import {
|
import Link from 'next/link';
|
||||||
fetchNotificationPref,
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
saveNotificationPref,
|
|
||||||
type NotificationPref,
|
|
||||||
} from '@/lib/tender-radar-api';
|
|
||||||
import { EmailAlertConfigForm } from './components/EmailAlertConfigForm';
|
|
||||||
import { RssFeedListForm } from './components/RssFeedListForm';
|
import { RssFeedListForm } from './components/RssFeedListForm';
|
||||||
import { SourceConfigForm } from './components/SourceConfigForm';
|
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
|
* Through Plan 02 this page carried four sections spanning three different
|
||||||
* Quelle im Admin-Bereich konfigurierbar") — the frontend half of the
|
* responsibilities: platform administration (poll interval, RSS feeds),
|
||||||
* requirement, driving the Plan 05 GET/PUT /modules/tender-radar/source-config
|
* tenant/user administration (mailbox), and a purely personal setting
|
||||||
* endpoint via SourceConfigForm.
|
* (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
|
* - `SourceConfigForm` (D-03): the DÖE poll interval. There is exactly one
|
||||||
* below: a Täglich/Wöchentlich/Aus selector for this user's digest interval
|
* public source, polling it per-user would just fetch the same data
|
||||||
* preference, loaded via fetchNotificationPref on mount and saved via
|
* multiple times.
|
||||||
* saveNotificationPref on change. Kept inline here (the page is already a
|
* - `RssFeedListForm scope="platform"` (D-02): admin-managed feeds that
|
||||||
* 'use client' shell, unlike the admin-only SourceConfigForm split) rather
|
* apply to every user, e.g. the service.bund.de default seeded since
|
||||||
* than split into a separate component — this is a single select, no
|
* Phase 14. Personal feeds live on "Meine Quellen" instead and never
|
||||||
* standalone unit-test coverage was called for in this plan.
|
* 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
|
* Role check (the original backlog trigger, `2026-08-11-tender-radar-
|
||||||
* SourceConfigForm: RssFeedListForm, the admin CRUD list for the GLOBAL
|
* einstellungen-mischen-rollen.md`, open point 5): this check is DISPLAY
|
||||||
* (not per-tenant) RSS feed sources. Extends this existing settings page
|
* ONLY. It hides the two admin sections from a normal user so they never
|
||||||
* rather than building a new one (D-09).
|
* 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"
|
* No module-loader whitelist change needed here — nested route under the
|
||||||
* section: EmailAlertConfigForm, the PER-TENANT portal-alert mailbox
|
* already-whitelisted `tender-radar` module page (Plan 10-02).
|
||||||
* 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).
|
|
||||||
*
|
*
|
||||||
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
|
||||||
* tenderRadar i18n namespace.
|
* tenderRadar i18n namespace.
|
||||||
*/
|
*/
|
||||||
export default function TenderRadarSettingsPage() {
|
export default function TenderRadarSettingsPage() {
|
||||||
const t = useTranslations('tenderRadar');
|
const t = useTranslations('tenderRadar');
|
||||||
const [digestInterval, setDigestInterval] = useState<
|
const user = useAuthStore((s) => s.user);
|
||||||
NotificationPref['digestInterval']
|
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||||
>('daily');
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (user === null) {
|
||||||
fetchNotificationPref()
|
return (
|
||||||
.then((pref) => setDigestInterval(pref.digestInterval))
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
.catch((err) => {
|
<div className="mb-6 h-8 w-64 rounded bg-muted animate-pulse" />
|
||||||
setError(
|
<div className="h-40 rounded bg-muted animate-pulse" />
|
||||||
err instanceof Error ? err.message : t('settings.errorLoad'),
|
</div>
|
||||||
);
|
);
|
||||||
})
|
}
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = async (value: string) => {
|
if (!isAdmin) {
|
||||||
const next = value as NotificationPref['digestInterval'];
|
return (
|
||||||
setDigestInterval(next);
|
<div className="mx-auto max-w-2xl p-6">
|
||||||
setError(null);
|
<h1 className="text-2xl font-semibold tracking-tight mb-4">
|
||||||
setSaveSuccess(false);
|
{t('settings.title')}
|
||||||
setIsSaving(true);
|
</h1>
|
||||||
try {
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
const result = await saveNotificationPref(next);
|
{t('settings.accessDeniedText')}
|
||||||
setDigestInterval(result.digestInterval);
|
</p>
|
||||||
setSaveSuccess(true);
|
<Link
|
||||||
} catch (err) {
|
href="/modules/tender-radar/my-sources"
|
||||||
setError(
|
className="text-sm text-primary hover:underline"
|
||||||
err instanceof Error ? err.message : t('settings.errorSave'),
|
>
|
||||||
);
|
{t('mySources.title')} →
|
||||||
} finally {
|
</Link>
|
||||||
setIsSaving(false);
|
</div>
|
||||||
}
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl p-6">
|
<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">
|
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||||
{t('settings.rssSectionTitle')}
|
{t('settings.rssSectionTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4 text-xs text-muted-foreground">
|
<p className="mb-1 text-xs text-muted-foreground">
|
||||||
{t('settings.rssSectionBody')}
|
{t('settings.rssSectionBody')}
|
||||||
</p>
|
</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">
|
<p className="mb-4 text-xs text-muted-foreground">
|
||||||
{t('settings.emailSectionBody')}
|
{t('settings.rssSectionUserNote')}
|
||||||
</p>
|
</p>
|
||||||
<EmailAlertConfigForm />
|
<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.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>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -681,6 +681,7 @@
|
|||||||
"title": "Ausschreibungs-Radar",
|
"title": "Ausschreibungs-Radar",
|
||||||
"loading": "Lädt…",
|
"loading": "Lädt…",
|
||||||
"settingsTitle": "Einstellungen",
|
"settingsTitle": "Einstellungen",
|
||||||
|
"mySourcesTitle": "Meine Quellen",
|
||||||
"pollNow": "Jetzt abrufen",
|
"pollNow": "Jetzt abrufen",
|
||||||
"pollNowTitle": "Fällige Quellen jetzt abrufen",
|
"pollNowTitle": "Fällige Quellen jetzt abrufen",
|
||||||
"polling": "Wird abgerufen…",
|
"polling": "Wird abgerufen…",
|
||||||
@@ -857,9 +858,8 @@
|
|||||||
"title": "Ausschreibungs-Radar — Einstellungen",
|
"title": "Ausschreibungs-Radar — Einstellungen",
|
||||||
"rssSectionTitle": "RSS-Feeds",
|
"rssSectionTitle": "RSS-Feeds",
|
||||||
"rssSectionBody": "Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten gleich, nicht pro Mandant konfigurierbar.",
|
"rssSectionBody": "Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten gleich, nicht pro Mandant konfigurierbar.",
|
||||||
"emailSectionTitle": "E-Mail-Alerts",
|
"rssSectionUserNote": "Persönliche Feeds legen Sie auf „Meine Quellen\" an.",
|
||||||
"emailSectionBody": "Postfach, in das Vergabeportale Ihre Benachrichtigungsmails schicken — pro Mandant konfigurierbar, getrennt vom DKV-Rechnungspostfach. Aus diesen Mails erkannte Ausschreibungen sind nur für Ihren Mandanten sichtbar.",
|
"accessDeniedText": "Diese Seite verwaltet plattformweite Einstellungen und ist Administratoren vorbehalten.",
|
||||||
"notificationsSectionTitle": "Benachrichtigungen",
|
|
||||||
"digestIntervalLabel": "Digest-Intervall",
|
"digestIntervalLabel": "Digest-Intervall",
|
||||||
"digestDaily": "Täglich",
|
"digestDaily": "Täglich",
|
||||||
"digestWeekly": "Wöchentlich",
|
"digestWeekly": "Wöchentlich",
|
||||||
|
|||||||
@@ -681,6 +681,7 @@
|
|||||||
"title": "Tender Radar",
|
"title": "Tender Radar",
|
||||||
"loading": "Loading…",
|
"loading": "Loading…",
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
|
"mySourcesTitle": "My sources",
|
||||||
"pollNow": "Fetch now",
|
"pollNow": "Fetch now",
|
||||||
"pollNowTitle": "Fetch due sources now",
|
"pollNowTitle": "Fetch due sources now",
|
||||||
"polling": "Fetching…",
|
"polling": "Fetching…",
|
||||||
@@ -857,9 +858,8 @@
|
|||||||
"title": "Tender Radar — Settings",
|
"title": "Tender Radar — Settings",
|
||||||
"rssSectionTitle": "RSS Feeds",
|
"rssSectionTitle": "RSS Feeds",
|
||||||
"rssSectionBody": "Public, platform-wide RSS sources (e.g. service.bund.de or a subreport-elvis municipal feed) — applies equally to all tenants, not configurable per tenant.",
|
"rssSectionBody": "Public, platform-wide RSS sources (e.g. service.bund.de or a subreport-elvis municipal feed) — applies equally to all tenants, not configurable per tenant.",
|
||||||
"emailSectionTitle": "Email Alerts",
|
"rssSectionUserNote": "Add personal feeds under \"My sources\".",
|
||||||
"emailSectionBody": "Mailbox that tender portals send their notification emails to — configurable per tenant, separate from the DKV invoice mailbox. Tenders detected from these emails are only visible to your tenant.",
|
"accessDeniedText": "This page manages platform-wide settings and is reserved for administrators.",
|
||||||
"notificationsSectionTitle": "Notifications",
|
|
||||||
"digestIntervalLabel": "Digest Interval",
|
"digestIntervalLabel": "Digest Interval",
|
||||||
"digestDaily": "Daily",
|
"digestDaily": "Daily",
|
||||||
"digestWeekly": "Weekly",
|
"digestWeekly": "Weekly",
|
||||||
|
|||||||
Reference in New Issue
Block a user