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
This commit is contained in:
2026-07-21 11:24:32 +02:00
parent 6d6302294e
commit 4360bc0c6b
3 changed files with 324 additions and 0 deletions
@@ -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<string | null>(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<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(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 (
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i}>
<div className="mb-1 h-4 w-32 rounded bg-muted animate-pulse" />
<div className="h-9 max-w-md rounded bg-muted animate-pulse" />
</div>
))}
</div>
);
}
return (
<div className="space-y-4">
{/* Read-only display fields */}
<div>
<span className={labelCls}>Quelle</span>
<p className="text-sm text-foreground">{sourceType || 'doe-opendata'}</p>
</div>
<div>
<span className={labelCls}>Zuletzt erfasster Tag</span>
<p className="text-sm text-foreground">
{lastIngestedDay
? new Date(lastIngestedDay).toLocaleDateString('de-DE')
: 'Noch keine Erfassung'}
</p>
</div>
{/* Abrufintervall */}
<div>
<label htmlFor="source-config-poll-interval" className={labelCls}>
Abrufintervall (Minuten) *
</label>
<div className="flex items-center gap-2">
<input
id="source-config-poll-interval"
type="number"
required
min={MIN_POLL_INTERVAL_MIN}
max={MAX_POLL_INTERVAL_MIN}
className={inputCls}
value={pollIntervalMin}
onChange={(e) => handleIntervalChange(e.target.value)}
/>
<span className="text-sm text-muted-foreground">Min.</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Cron-Taktfrequenz ({MIN_POLL_INTERVAL_MIN}–{MAX_POLL_INTERVAL_MIN} Min.) — der eigentliche DÖE-Abruf ist zusätzlich über den Tages-Cursor gedrosselt.
</p>
</div>
{/* Aktiv — toggle switch */}
<div className="flex items-center gap-3">
<label
htmlFor="source-config-active"
className="text-sm text-foreground cursor-pointer"
>
Aktiv
</label>
<button
id="source-config-active"
type="button"
role="switch"
aria-checked={isActive}
onClick={handleActiveToggle}
className="relative flex-shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
style={{
width: 40,
height: 22,
background: isActive ? 'var(--primary)' : 'var(--muted)',
}}
>
<span
className="absolute top-0.5 rounded-full bg-white shadow transition-transform"
style={{
width: 18,
height: 18,
left: 2,
transform: isActive ? 'translateX(18px)' : 'translateX(0)',
}}
/>
</button>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-border mt-6">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? 'Speichert...' : 'Speichern'}
</button>
</div>
{validationError && (
<p className="text-sm text-destructive">{validationError}</p>
)}
{saveSuccess && (
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellungen gespeichert.
</p>
)}
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
</div>
);
}
@@ -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 (
<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>
);
}