From 4360bc0c6b8316981da2f3535a00d76f4a368205 Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 21 Jul 2026 11:24:32 +0200 Subject: [PATCH] feat(10-06): tender-radar-api client + admin source-config settings form - tender-radar-api.ts: fetchSourceConfig/saveSourceConfig hitting GET/PUT /modules/tender-radar/source-config (Plan 05 endpoint) - SourceConfigForm: load-on-mount, numeric interval (5-1440 min, mirrors backend SourceConfigDto bounds) + isActive toggle, read-only sourceType/ lastIngestedDay display - settings/page.tsx: standard App Router route rendering the form, no module-loader whitelist change needed --- .../settings/components/SourceConfigForm.tsx | 227 ++++++++++++++++++ .../modules/tender-radar/settings/page.tsx | 30 +++ apps/web/src/lib/tender-radar-api.ts | 67 ++++++ 3 files changed, 324 insertions(+) create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx create mode 100644 apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx create mode 100644 apps/web/src/lib/tender-radar-api.ts diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx new file mode 100644 index 0000000..79c3ff2 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + type SourceConfig, + fetchSourceConfig, + saveSourceConfig, +} from '@/lib/tender-radar-api'; + +/** Client-side bounds mirroring the backend SourceConfigDto (T-10-17). */ +const MIN_POLL_INTERVAL_MIN = 5; +const MAX_POLL_INTERVAL_MIN = 1440; + +/** + * Admin-facing config form for the shared, platform-wide DÖE poll + * (INGEST-06: "Intervall pro Quelle im Admin-Bereich konfigurierbar"). + * + * Much simpler analog of DKV's InboxConfigForm — no credentials/password, + * since the DÖE source has no auth surface (T-10-18). Fields: numeric + * pollIntervalMin (cron-tick frequency, D-04 default 60 — decoupled from + * the day-granularity DÖE fetch itself, which is gated by the backend's + * day-cursor) and an isActive toggle. sourceType/lastIngestedDay are shown + * read-only so the admin can see the day-cursor state. + * + * Hardcoded German strings for now — full i18n is CONFIG-03 (Phase 14), + * matching the intentional MVP-stub convention already used by the + * tender-radar placeholder page (Plan 10-02). + */ +export function SourceConfigForm() { + const [sourceType, setSourceType] = useState(''); + const [lastIngestedDay, setLastIngestedDay] = useState(null); + const [pollIntervalMin, setPollIntervalMin] = useState('60'); + const [isActive, setIsActive] = useState(false); + + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const [validationError, setValidationError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + + // Load config on mount + useEffect(() => { + fetchSourceConfig() + .then((config: SourceConfig) => { + setSourceType(config.sourceType); + setLastIngestedDay(config.lastIngestedDay ?? null); + setPollIntervalMin(String(config.pollIntervalMin)); + setIsActive(config.isActive); + }) + .catch((err) => { + setSaveError( + err instanceof Error + ? err.message + : 'Konfiguration konnte nicht geladen werden', + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + const handleIntervalChange = (value: string) => { + setPollIntervalMin(value); + setValidationError(null); + setSaveError(null); + setSaveSuccess(false); + }; + + const handleActiveToggle = () => { + setIsActive((v) => !v); + setSaveError(null); + setSaveSuccess(false); + }; + + const handleSave = async () => { + setValidationError(null); + setSaveError(null); + setSaveSuccess(false); + + const parsedInterval = Number(pollIntervalMin); + if ( + !Number.isFinite(parsedInterval) || + parsedInterval < MIN_POLL_INTERVAL_MIN || + parsedInterval > MAX_POLL_INTERVAL_MIN + ) { + setValidationError( + `Intervall muss zwischen ${MIN_POLL_INTERVAL_MIN} und ${MAX_POLL_INTERVAL_MIN} Minuten liegen.`, + ); + return; + } + + setIsSaving(true); + try { + const result = await saveSourceConfig({ + pollIntervalMin: parsedInterval, + isActive, + }); + setSourceType(result.sourceType); + setLastIngestedDay(result.lastIngestedDay ?? null); + setPollIntervalMin(String(result.pollIntervalMin)); + setIsActive(result.isActive); + setSaveSuccess(true); + } catch (err) { + setSaveError( + err instanceof Error + ? err.message + : 'Einstellungen konnten nicht gespeichert werden', + ); + } finally { + setIsSaving(false); + } + }; + + const inputCls = + 'h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground'; + const labelCls = 'mb-1 block text-sm text-foreground'; + + if (isLoading) { + return ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+ {/* Read-only display fields */} +
+ Quelle +

{sourceType || 'doe-opendata'}

+
+
+ Zuletzt erfasster Tag +

+ {lastIngestedDay + ? new Date(lastIngestedDay).toLocaleDateString('de-DE') + : 'Noch keine Erfassung'} +

+
+ + {/* Abrufintervall */} +
+ +
+ handleIntervalChange(e.target.value)} + /> + Min. +
+

+ Cron-Taktfrequenz ({MIN_POLL_INTERVAL_MIN}–{MAX_POLL_INTERVAL_MIN} Min.) — der eigentliche DÖE-Abruf ist zusätzlich über den Tages-Cursor gedrosselt. +

+
+ + {/* Aktiv — toggle switch */} +
+ + +
+ + {/* Actions */} +
+ +
+ + {validationError && ( +

{validationError}

+ )} + {saveSuccess && ( +

+ Einstellungen gespeichert. +

+ )} + {saveError &&

{saveError}

} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx new file mode 100644 index 0000000..ebcf0f5 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { SourceConfigForm } from './components/SourceConfigForm'; + +/** + * Ausschreibungs-Radar module settings page. + * + * 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. + * + * 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). + * + * Intentional hardcoded German string: full i18n (next-intl message keys) + * is CONFIG-03, planned for Phase 14 — matching the convention already + * used by the tender-radar placeholder page. + */ +export default function TenderRadarSettingsPage() { + return ( +
+

+ Ausschreibungs-Radar — Einstellungen +

+ +
+ ); +} diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts new file mode 100644 index 0000000..03a5d92 --- /dev/null +++ b/apps/web/src/lib/tender-radar-api.ts @@ -0,0 +1,67 @@ +/** + * Ausschreibungs-Radar (tender-radar) module API client. + * Consumes the /modules/tender-radar/* REST surface built in Plan 05. + * All calls use credentials: 'include' for cookie-based auth. + * + * Security: the DÖE source config carries no secrets (no username/password + * field, unlike DkvConfig) — T-10-18 accepts this as low-risk information + * disclosure since the config fetch response is auth-free by nature. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +// --- Types mirroring the Plan 05 backend contract (TenderSourcePollConfig) --- + +/** + * Singleton `doe-opendata` poll config. + * `pollIntervalMin` is the cron-tick frequency (D-04 default 60) — NOT the + * DÖE fetch frequency, which is gated separately by the day-cursor inside + * the backend's TenderIngestionService (day-granular source). + */ +export interface SourceConfig { + /** Fixed slug identifying the source, e.g. 'doe-opendata'. Read-only display field. */ + sourceType: string; + pollIntervalMin: number; + isActive: boolean; + /** Day-cursor of the last successfully ingested day. Read-only display field. */ + lastIngestedDay?: string | null; +} + +/** Payload accepted by PUT /modules/tender-radar/source-config. */ +export interface SaveSourceConfigPayload { + pollIntervalMin: number; + isActive: boolean; +} + +// --- API functions --- + +/** + * Fetch the current shared DÖE source poll config. + * GET /modules/tender-radar/source-config + */ +export async function fetchSourceConfig(): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/source-config`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch tender-radar source config'); + return res.json(); +} + +/** + * Save the shared DÖE source poll config. + * PUT /modules/tender-radar/source-config + * Saving live-applies the change to the running scheduler (INGEST-06, + * no restart required — see Plan 05). + */ +export async function saveSourceConfig( + payload: SaveSourceConfigPayload, +): Promise { + const res = await fetch(`${API_URL}/modules/tender-radar/source-config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to save tender-radar source config'); + return res.json(); +}