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:
+227
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<SourceConfig> {
|
||||||
|
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<SourceConfig> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user