feat(14-05): convert tender-radar settings + config forms to useTranslations

Converts settings/page.tsx, SourceConfigForm, RssFeedListForm and
EmailAlertConfigForm from hardcoded German strings to
useTranslations('tenderRadar'). Interval bound and RSS-feed removal
validation/error messages use next-intl interpolation ({min}/{max},
{label}). Updates the three affected settings component tests with a
next-intl useTranslations mock mirroring the marketplace test convention.
The entire tender-radar module UI (results, filters, saved searches,
detail, coverage, settings, RSS/email forms) now honors the selected
locale (CONFIG-03, D-10) with no language switcher added (D-11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:20:52 +02:00
parent 2ea02a2671
commit 849aa9b0a9
7 changed files with 198 additions and 101 deletions
@@ -10,6 +10,46 @@ vi.mock('@/lib/tender-radar-api', () => ({
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args), saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx).
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => {
const translations: Record<string, string> = {
'emailAlerts.notSaved': 'Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und speichern.',
'emailAlerts.protocolLabel': 'Protokoll *',
'emailAlerts.protocolImap': 'IMAP',
'emailAlerts.protocolExchange': 'Exchange',
'emailAlerts.hostLabelExchange': 'EWS-Endpunkt-URL',
'emailAlerts.hostLabelImap': 'IMAP-Server',
'emailAlerts.hostPlaceholderExchange': 'https://mail.firma.de/EWS/Exchange.asmx',
'emailAlerts.hostPlaceholderImap': 'imap.firma.de',
'emailAlerts.portLabel': 'Port *',
'emailAlerts.encryptionLabel': 'Verschlüsselung *',
'emailAlerts.encryptionNone': 'Keine',
'emailAlerts.encryptionStarttls': 'STARTTLS',
'emailAlerts.encryptionSslTls': 'SSL-TLS',
'emailAlerts.folderLabel': 'Ordner',
'emailAlerts.folderPlaceholderExchange': 'Inbox',
'emailAlerts.folderPlaceholderImap': 'INBOX',
'emailAlerts.domainLabel': 'Windows-Domäne',
'emailAlerts.domainPlaceholder': 'CONTOSO',
'emailAlerts.senderFilterLabel': 'Absenderfilter',
'emailAlerts.senderFilterPlaceholder': 'alerts@vergabeportal.de',
'emailAlerts.usernameLabel': 'Benutzername',
'emailAlerts.passwordLabel': 'Passwort',
'emailAlerts.passwordPlaceholder': '••••••••',
'emailAlerts.showPassword': 'Passwort anzeigen',
'emailAlerts.hidePassword': 'Passwort verbergen',
'emailAlerts.activeLabel': 'Aktiv',
'emailAlerts.saving': 'Wird gespeichert...',
'emailAlerts.save': 'Speichern',
'emailAlerts.saveSuccess': 'Einstellungen gespeichert.',
'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden',
};
return translations[key] ?? key;
},
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
mockFetchEmailConfig.mockReset(); mockFetchEmailConfig.mockReset();
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
type EmailAlertConfig, type EmailAlertConfig,
fetchEmailConfig, fetchEmailConfig,
@@ -129,11 +130,11 @@ function formToPayload(
* test-connection endpoint exists for this config (out of this plan's * test-connection endpoint exists for this config (out of this plan's
* scope). * scope).
* *
* Hardcoded German strings — full i18n is CONFIG-03 (Plan 14-05), matching * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* the same intentional convention already used by SourceConfigForm/ * tenderRadar i18n namespace.
* RssFeedListForm.
*/ */
export function EmailAlertConfigForm() { export function EmailAlertConfigForm() {
const t = useTranslations('tenderRadar');
const [form, setForm] = useState<FormState>(DEFAULT_FORM); const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -182,9 +183,7 @@ export function EmailAlertConfigForm() {
setConfigExists(true); setConfigExists(true);
} catch (err) { } catch (err) {
setSaveError( setSaveError(
err instanceof Error err instanceof Error ? err.message : t('emailAlerts.errorSave'),
? err.message
: 'Einstellungen konnten nicht gespeichert werden',
); );
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -212,15 +211,14 @@ export function EmailAlertConfigForm() {
<div className="space-y-4"> <div className="space-y-4">
{!configExists && ( {!configExists && (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"> <div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und {t('emailAlerts.notSaved')}
speichern.
</div> </div>
)} )}
{/* Protokoll */} {/* Protokoll */}
<div> <div>
<label htmlFor="email-alert-protocol" className={labelCls}> <label htmlFor="email-alert-protocol" className={labelCls}>
Protokoll * {t('emailAlerts.protocolLabel')}
</label> </label>
<select <select
id="email-alert-protocol" id="email-alert-protocol"
@@ -230,15 +228,17 @@ export function EmailAlertConfigForm() {
update('protocol', e.target.value as 'imap' | 'exchange') update('protocol', e.target.value as 'imap' | 'exchange')
} }
> >
<option value="imap">IMAP</option> <option value="imap">{t('emailAlerts.protocolImap')}</option>
<option value="exchange">Exchange</option> <option value="exchange">{t('emailAlerts.protocolExchange')}</option>
</select> </select>
</div> </div>
{/* Host / EWS-URL */} {/* Host / EWS-URL */}
<div> <div>
<label htmlFor="email-alert-host" className={labelCls}> <label htmlFor="email-alert-host" className={labelCls}>
{form.protocol === 'exchange' ? 'EWS-Endpunkt-URL' : 'IMAP-Server'} * {(form.protocol === 'exchange'
? t('emailAlerts.hostLabelExchange')
: t('emailAlerts.hostLabelImap')) + ' *'}
</label> </label>
<input <input
id="email-alert-host" id="email-alert-host"
@@ -246,8 +246,8 @@ export function EmailAlertConfigForm() {
required required
placeholder={ placeholder={
form.protocol === 'exchange' form.protocol === 'exchange'
? 'https://mail.firma.de/EWS/Exchange.asmx' ? t('emailAlerts.hostPlaceholderExchange')
: 'imap.firma.de' : t('emailAlerts.hostPlaceholderImap')
} }
className={inputCls} className={inputCls}
value={form.host} value={form.host}
@@ -259,7 +259,7 @@ export function EmailAlertConfigForm() {
{form.protocol === 'imap' && ( {form.protocol === 'imap' && (
<div> <div>
<label htmlFor="email-alert-port" className={labelCls}> <label htmlFor="email-alert-port" className={labelCls}>
Port * {t('emailAlerts.portLabel')}
</label> </label>
<input <input
id="email-alert-port" id="email-alert-port"
@@ -276,7 +276,7 @@ export function EmailAlertConfigForm() {
{form.protocol === 'imap' && ( {form.protocol === 'imap' && (
<div> <div>
<label htmlFor="email-alert-encryption" className={labelCls}> <label htmlFor="email-alert-encryption" className={labelCls}>
Verschlüsselung * {t('emailAlerts.encryptionLabel')}
</label> </label>
<select <select
id="email-alert-encryption" id="email-alert-encryption"
@@ -289,9 +289,9 @@ export function EmailAlertConfigForm() {
) )
} }
> >
<option value="none">Keine</option> <option value="none">{t('emailAlerts.encryptionNone')}</option>
<option value="starttls">STARTTLS</option> <option value="starttls">{t('emailAlerts.encryptionStarttls')}</option>
<option value="ssl-tls">SSL-TLS</option> <option value="ssl-tls">{t('emailAlerts.encryptionSslTls')}</option>
</select> </select>
</div> </div>
)} )}
@@ -299,12 +299,16 @@ export function EmailAlertConfigForm() {
{/* Ordner / Postfach */} {/* Ordner / Postfach */}
<div> <div>
<label htmlFor="email-alert-folder" className={labelCls}> <label htmlFor="email-alert-folder" className={labelCls}>
Ordner {t('emailAlerts.folderLabel')}
</label> </label>
<input <input
id="email-alert-folder" id="email-alert-folder"
type="text" type="text"
placeholder={form.protocol === 'exchange' ? 'Inbox' : 'INBOX'} placeholder={
form.protocol === 'exchange'
? t('emailAlerts.folderPlaceholderExchange')
: t('emailAlerts.folderPlaceholderImap')
}
className={inputCls} className={inputCls}
value={form.folder} value={form.folder}
onChange={(e) => update('folder', e.target.value)} onChange={(e) => update('folder', e.target.value)}
@@ -315,12 +319,12 @@ export function EmailAlertConfigForm() {
{form.protocol === 'exchange' && ( {form.protocol === 'exchange' && (
<div> <div>
<label htmlFor="email-alert-domain" className={labelCls}> <label htmlFor="email-alert-domain" className={labelCls}>
Windows-Domäne {t('emailAlerts.domainLabel')}
</label> </label>
<input <input
id="email-alert-domain" id="email-alert-domain"
type="text" type="text"
placeholder="CONTOSO" placeholder={t('emailAlerts.domainPlaceholder')}
className={inputCls} className={inputCls}
value={form.domain} value={form.domain}
onChange={(e) => update('domain', e.target.value)} onChange={(e) => update('domain', e.target.value)}
@@ -331,12 +335,12 @@ export function EmailAlertConfigForm() {
{/* Absenderfilter */} {/* Absenderfilter */}
<div> <div>
<label htmlFor="email-alert-sender-filter" className={labelCls}> <label htmlFor="email-alert-sender-filter" className={labelCls}>
Absenderfilter {t('emailAlerts.senderFilterLabel')}
</label> </label>
<input <input
id="email-alert-sender-filter" id="email-alert-sender-filter"
type="email" type="email"
placeholder="alerts@vergabeportal.de" placeholder={t('emailAlerts.senderFilterPlaceholder')}
className={inputCls} className={inputCls}
value={form.senderFilter} value={form.senderFilter}
onChange={(e) => update('senderFilter', e.target.value)} onChange={(e) => update('senderFilter', e.target.value)}
@@ -346,7 +350,7 @@ export function EmailAlertConfigForm() {
{/* Benutzername */} {/* Benutzername */}
<div> <div>
<label htmlFor="email-alert-username" className={labelCls}> <label htmlFor="email-alert-username" className={labelCls}>
Benutzername {t('emailAlerts.usernameLabel')}
</label> </label>
<input <input
id="email-alert-username" id="email-alert-username"
@@ -360,7 +364,7 @@ export function EmailAlertConfigForm() {
{/* Passwort — T-07-12: never pre-filled from server */} {/* Passwort — T-07-12: never pre-filled from server */}
<div> <div>
<label htmlFor="email-alert-password" className={labelCls}> <label htmlFor="email-alert-password" className={labelCls}>
Passwort {t('emailAlerts.passwordLabel')}
</label> </label>
<div className="relative max-w-md"> <div className="relative max-w-md">
<input <input
@@ -368,14 +372,14 @@ export function EmailAlertConfigForm() {
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground" className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground"
value={form.password} value={form.password}
placeholder="••••••••" placeholder={t('emailAlerts.passwordPlaceholder')}
onChange={(e) => update('password', e.target.value)} onChange={(e) => update('password', e.target.value)}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'} aria-label={showPassword ? t('emailAlerts.hidePassword') : t('emailAlerts.showPassword')}
> >
{showPassword ? <EyeOffIcon /> : <EyeIcon />} {showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button> </button>
@@ -388,7 +392,7 @@ export function EmailAlertConfigForm() {
htmlFor="email-alert-active" htmlFor="email-alert-active"
className="text-sm text-foreground cursor-pointer" className="text-sm text-foreground cursor-pointer"
> >
Aktiv {t('emailAlerts.activeLabel')}
</label> </label>
<button <button
id="email-alert-active" id="email-alert-active"
@@ -423,13 +427,13 @@ export function EmailAlertConfigForm() {
disabled={isSaving} 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" 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 ? 'Wird gespeichert...' : 'Speichern'} {isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button> </button>
</div> </div>
{saveSuccess && ( {saveSuccess && (
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}> <p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellungen gespeichert. {t('emailAlerts.saveSuccess')}
</p> </p>
)} )}
{saveError && <p className="text-sm text-destructive">{saveError}</p>} {saveError && <p className="text-sm text-destructive">{saveError}</p>}
@@ -12,6 +12,38 @@ vi.mock('@/lib/tender-radar-api', () => ({
deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args), deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx) with {param} interpolation.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
'rssFeeds.errorLoad': 'RSS-Feeds konnten nicht geladen werden',
'rssFeeds.errorAdd': 'RSS-Feed konnte nicht gespeichert werden',
'rssFeeds.errorRemove': 'RSS-Feed konnte nicht entfernt werden',
'rssFeeds.validationRequired': 'URL und Bezeichnung sind erforderlich.',
'rssFeeds.empty': 'Noch keine RSS-Feeds hinterlegt.',
'rssFeeds.activeLabel': 'aktiv',
'rssFeeds.inactiveLabel': 'inaktiv',
'rssFeeds.removeAria': 'RSS-Feed "{label}" entfernen',
'rssFeeds.removing': 'Entfernt...',
'rssFeeds.remove': 'Entfernen',
'rssFeeds.urlLabel': 'Feed-URL *',
'rssFeeds.urlPlaceholder': 'https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml',
'rssFeeds.labelLabel': 'Bezeichnung *',
'rssFeeds.labelPlaceholder': 'z. B. service-bund',
'rssFeeds.adding': 'Wird hinzugefügt...',
'rssFeeds.add': 'Feed hinzufügen',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, v);
}
}
return result;
},
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
mockListRssFeeds.mockReset(); mockListRssFeeds.mockReset();
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
type RssFeedSource, type RssFeedSource,
createRssFeed, createRssFeed,
@@ -22,10 +23,11 @@ import {
* für automatisierten Zugriff gesperrt...") via `createRssFeed`'s relayed * für automatisierten Zugriff gesperrt...") via `createRssFeed`'s relayed
* error message. * error message.
* *
* Hardcoded German strings for now — full i18n is CONFIG-03 (Plan 14-05), * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* matching the same intentional MVP-stub convention as SourceConfigForm. * tenderRadar i18n namespace.
*/ */
export function RssFeedListForm() { export function RssFeedListForm() {
const t = useTranslations('tenderRadar');
const [feeds, setFeeds] = useState<RssFeedSource[]>([]); const [feeds, setFeeds] = useState<RssFeedSource[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
@@ -47,9 +49,7 @@ export function RssFeedListForm() {
}) })
.catch((err) => { .catch((err) => {
setLoadError( setLoadError(
err instanceof Error err instanceof Error ? err.message : t('rssFeeds.errorLoad'),
? err.message
: 'RSS-Feeds konnten nicht geladen werden',
); );
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
@@ -57,13 +57,14 @@ export function RssFeedListForm() {
useEffect(() => { useEffect(() => {
loadFeeds(); loadFeeds();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleAdd = async () => { const handleAdd = async () => {
setAddError(null); setAddError(null);
if (!url.trim() || !label.trim()) { if (!url.trim() || !label.trim()) {
setAddError('URL und Bezeichnung sind erforderlich.'); setAddError(t('rssFeeds.validationRequired'));
return; return;
} }
@@ -75,9 +76,7 @@ export function RssFeedListForm() {
setLabel(''); setLabel('');
} catch (err) { } catch (err) {
setAddError( setAddError(
err instanceof Error err instanceof Error ? err.message : t('rssFeeds.errorAdd'),
? err.message
: 'RSS-Feed konnte nicht gespeichert werden',
); );
} finally { } finally {
setIsAdding(false); setIsAdding(false);
@@ -92,9 +91,7 @@ export function RssFeedListForm() {
setFeeds((prev) => prev.filter((feed) => feed.id !== id)); setFeeds((prev) => prev.filter((feed) => feed.id !== id));
} catch (err) { } catch (err) {
setRemoveError( setRemoveError(
err instanceof Error err instanceof Error ? err.message : t('rssFeeds.errorRemove'),
? err.message
: 'RSS-Feed konnte nicht entfernt werden',
); );
} finally { } finally {
setRemovingId(null); setRemovingId(null);
@@ -124,7 +121,7 @@ export function RssFeedListForm() {
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{feed.label}{' '} {feed.label}{' '}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
({feed.isActive ? 'aktiv' : 'inaktiv'}) ({feed.isActive ? t('rssFeeds.activeLabel') : t('rssFeeds.inactiveLabel')})
</span> </span>
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
@@ -135,16 +132,16 @@ export function RssFeedListForm() {
type="button" type="button"
onClick={() => handleRemove(feed.id)} onClick={() => handleRemove(feed.id)}
disabled={removingId === feed.id} disabled={removingId === feed.id}
aria-label={`RSS-Feed "${feed.label}" entfernen`} aria-label={t('rssFeeds.removeAria', { label: feed.label })}
className="shrink-0 rounded px-2 py-1 text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50 disabled:cursor-not-allowed" className="shrink-0 rounded px-2 py-1 text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{removingId === feed.id ? 'Entfernt...' : 'Entfernen'} {removingId === feed.id ? t('rssFeeds.removing') : t('rssFeeds.remove')}
</button> </button>
</li> </li>
))} ))}
{feeds.length === 0 && ( {feeds.length === 0 && (
<li className="text-sm text-muted-foreground"> <li className="text-sm text-muted-foreground">
Noch keine RSS-Feeds hinterlegt. {t('rssFeeds.empty')}
</li> </li>
)} )}
</ul> </ul>
@@ -156,13 +153,13 @@ export function RssFeedListForm() {
<div className="space-y-3 border-t border-border pt-4"> <div className="space-y-3 border-t border-border pt-4">
<div> <div>
<label htmlFor="rss-feed-url" className={labelCls}> <label htmlFor="rss-feed-url" className={labelCls}>
Feed-URL * {t('rssFeeds.urlLabel')}
</label> </label>
<input <input
id="rss-feed-url" id="rss-feed-url"
type="url" type="url"
required required
placeholder="https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml" placeholder={t('rssFeeds.urlPlaceholder')}
className={inputCls} className={inputCls}
value={url} value={url}
onChange={(e) => { onChange={(e) => {
@@ -173,13 +170,13 @@ export function RssFeedListForm() {
</div> </div>
<div> <div>
<label htmlFor="rss-feed-label" className={labelCls}> <label htmlFor="rss-feed-label" className={labelCls}>
Bezeichnung * {t('rssFeeds.labelLabel')}
</label> </label>
<input <input
id="rss-feed-label" id="rss-feed-label"
type="text" type="text"
required required
placeholder="z. B. service-bund" placeholder={t('rssFeeds.labelPlaceholder')}
className={inputCls} className={inputCls}
value={label} value={label}
onChange={(e) => { onChange={(e) => {
@@ -194,7 +191,7 @@ export function RssFeedListForm() {
disabled={isAdding} disabled={isAdding}
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" 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"
> >
{isAdding ? 'Wird hinzugefügt...' : 'Feed hinzufügen'} {isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')}
</button> </button>
{addError && <p className="text-sm text-destructive">{addError}</p>} {addError && <p className="text-sm text-destructive">{addError}</p>}
</div> </div>
@@ -10,6 +10,36 @@ vi.mock('@/lib/tender-radar-api', () => ({
saveSourceConfig: (...args: unknown[]) => mockSaveSourceConfig(...args), saveSourceConfig: (...args: unknown[]) => mockSaveSourceConfig(...args),
})); }));
// Mock next-intl useTranslations — mirrors the marketplace test mock
// convention (tenant-selector.test.tsx) with {param} interpolation.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string>) => {
const translations: Record<string, string> = {
'sourceConfig.errorLoad': 'Konfiguration konnte nicht geladen werden',
'sourceConfig.errorSave': 'Einstellungen konnten nicht gespeichert werden',
'sourceConfig.validationError': 'Intervall muss zwischen {min} und {max} Minuten liegen.',
'sourceConfig.sourceLabel': 'Quelle',
'sourceConfig.lastIngestedLabel': 'Zuletzt erfasster Tag',
'sourceConfig.neverIngested': 'Noch keine Erfassung',
'sourceConfig.pollIntervalLabel': 'Abrufintervall (Minuten) *',
'sourceConfig.minutesSuffix': 'Min.',
'sourceConfig.pollIntervalHelp':
'Cron-Taktfrequenz ({min}–{max} Min.) — der eigentliche DÖE-Abruf ist zusätzlich über den Tages-Cursor gedrosselt.',
'sourceConfig.activeLabel': 'Aktiv',
'sourceConfig.saving': 'Speichert...',
'sourceConfig.save': 'Speichern',
'sourceConfig.saveSuccess': 'Einstellungen gespeichert.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, v);
}
}
return result;
},
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
// Reset call history AND implementations between tests — restoreAllMocks // Reset call history AND implementations between tests — restoreAllMocks
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
type SourceConfig, type SourceConfig,
fetchSourceConfig, fetchSourceConfig,
@@ -22,11 +23,11 @@ const MAX_POLL_INTERVAL_MIN = 1440;
* day-cursor) and an isActive toggle. sourceType/lastIngestedDay are shown * day-cursor) and an isActive toggle. sourceType/lastIngestedDay are shown
* read-only so the admin can see the day-cursor state. * read-only so the admin can see the day-cursor state.
* *
* Hardcoded German strings for now — full i18n is CONFIG-03 (Phase 14), * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* matching the intentional MVP-stub convention already used by the * tenderRadar i18n namespace.
* tender-radar placeholder page (Plan 10-02).
*/ */
export function SourceConfigForm() { export function SourceConfigForm() {
const t = useTranslations('tenderRadar');
const [sourceType, setSourceType] = useState(''); const [sourceType, setSourceType] = useState('');
const [lastIngestedDay, setLastIngestedDay] = useState<string | null>(null); const [lastIngestedDay, setLastIngestedDay] = useState<string | null>(null);
const [pollIntervalMin, setPollIntervalMin] = useState('60'); const [pollIntervalMin, setPollIntervalMin] = useState('60');
@@ -50,14 +51,13 @@ export function SourceConfigForm() {
}) })
.catch((err) => { .catch((err) => {
setSaveError( setSaveError(
err instanceof Error err instanceof Error ? err.message : t('sourceConfig.errorLoad'),
? err.message
: 'Konfiguration konnte nicht geladen werden',
); );
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleIntervalChange = (value: string) => { const handleIntervalChange = (value: string) => {
@@ -85,7 +85,10 @@ export function SourceConfigForm() {
parsedInterval > MAX_POLL_INTERVAL_MIN parsedInterval > MAX_POLL_INTERVAL_MIN
) { ) {
setValidationError( setValidationError(
`Intervall muss zwischen ${MIN_POLL_INTERVAL_MIN} und ${MAX_POLL_INTERVAL_MIN} Minuten liegen.`, t('sourceConfig.validationError', {
min: String(MIN_POLL_INTERVAL_MIN),
max: String(MAX_POLL_INTERVAL_MIN),
}),
); );
return; return;
} }
@@ -103,9 +106,7 @@ export function SourceConfigForm() {
setSaveSuccess(true); setSaveSuccess(true);
} catch (err) { } catch (err) {
setSaveError( setSaveError(
err instanceof Error err instanceof Error ? err.message : t('sourceConfig.errorSave'),
? err.message
: 'Einstellungen konnten nicht gespeichert werden',
); );
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -133,22 +134,22 @@ export function SourceConfigForm() {
<div className="space-y-4"> <div className="space-y-4">
{/* Read-only display fields */} {/* Read-only display fields */}
<div> <div>
<span className={labelCls}>Quelle</span> <span className={labelCls}>{t('sourceConfig.sourceLabel')}</span>
<p className="text-sm text-foreground">{sourceType || 'doe-opendata'}</p> <p className="text-sm text-foreground">{sourceType || 'doe-opendata'}</p>
</div> </div>
<div> <div>
<span className={labelCls}>Zuletzt erfasster Tag</span> <span className={labelCls}>{t('sourceConfig.lastIngestedLabel')}</span>
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
{lastIngestedDay {lastIngestedDay
? new Date(lastIngestedDay).toLocaleDateString('de-DE') ? new Date(lastIngestedDay).toLocaleDateString('de-DE')
: 'Noch keine Erfassung'} : t('sourceConfig.neverIngested')}
</p> </p>
</div> </div>
{/* Abrufintervall */} {/* Abrufintervall */}
<div> <div>
<label htmlFor="source-config-poll-interval" className={labelCls}> <label htmlFor="source-config-poll-interval" className={labelCls}>
Abrufintervall (Minuten) * {t('sourceConfig.pollIntervalLabel')}
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -161,10 +162,13 @@ export function SourceConfigForm() {
value={pollIntervalMin} value={pollIntervalMin}
onChange={(e) => handleIntervalChange(e.target.value)} onChange={(e) => handleIntervalChange(e.target.value)}
/> />
<span className="text-sm text-muted-foreground">Min.</span> <span className="text-sm text-muted-foreground">{t('sourceConfig.minutesSuffix')}</span>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <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. {t('sourceConfig.pollIntervalHelp', {
min: String(MIN_POLL_INTERVAL_MIN),
max: String(MAX_POLL_INTERVAL_MIN),
})}
</p> </p>
</div> </div>
@@ -174,7 +178,7 @@ export function SourceConfigForm() {
htmlFor="source-config-active" htmlFor="source-config-active"
className="text-sm text-foreground cursor-pointer" className="text-sm text-foreground cursor-pointer"
> >
Aktiv {t('sourceConfig.activeLabel')}
</label> </label>
<button <button
id="source-config-active" id="source-config-active"
@@ -209,7 +213,7 @@ export function SourceConfigForm() {
disabled={isSaving} 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" 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'} {isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
</button> </button>
</div> </div>
@@ -218,7 +222,7 @@ export function SourceConfigForm() {
)} )}
{saveSuccess && ( {saveSuccess && (
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}> <p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellungen gespeichert. {t('sourceConfig.saveSuccess')}
</p> </p>
)} )}
{saveError && <p className="text-sm text-destructive">{saveError}</p>} {saveError && <p className="text-sm text-destructive">{saveError}</p>}
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
fetchNotificationPref, fetchNotificationPref,
saveNotificationPref, saveNotificationPref,
@@ -40,11 +41,11 @@ import { SourceConfigForm } from './components/SourceConfigForm';
* Next.js App Router route nested under the already-whitelisted * Next.js App Router route nested under the already-whitelisted
* `tender-radar` module page (Plan 10-02). * `tender-radar` module page (Plan 10-02).
* *
* Intentional hardcoded German string: full i18n (next-intl message keys) * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* is CONFIG-03, planned for Phase 14 — matching the convention already * tenderRadar i18n namespace.
* used by the tender-radar placeholder page.
*/ */
export default function TenderRadarSettingsPage() { export default function TenderRadarSettingsPage() {
const t = useTranslations('tenderRadar');
const [digestInterval, setDigestInterval] = useState< const [digestInterval, setDigestInterval] = useState<
NotificationPref['digestInterval'] NotificationPref['digestInterval']
>('daily'); >('daily');
@@ -58,12 +59,11 @@ export default function TenderRadarSettingsPage() {
.then((pref) => setDigestInterval(pref.digestInterval)) .then((pref) => setDigestInterval(pref.digestInterval))
.catch((err) => { .catch((err) => {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('settings.errorLoad'),
? err.message
: 'Benachrichtigungseinstellung konnte nicht geladen werden',
); );
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const handleChange = async (value: string) => { const handleChange = async (value: string) => {
@@ -78,9 +78,7 @@ export default function TenderRadarSettingsPage() {
setSaveSuccess(true); setSaveSuccess(true);
} catch (err) { } catch (err) {
setError( setError(
err instanceof Error err instanceof Error ? err.message : t('settings.errorSave'),
? err.message
: 'Benachrichtigungseinstellung konnte nicht gespeichert werden',
); );
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -90,38 +88,33 @@ export default function TenderRadarSettingsPage() {
return ( return (
<div className="mx-auto max-w-2xl p-6"> <div className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-semibold tracking-tight mb-6"> <h1 className="text-2xl font-semibold tracking-tight mb-6">
Ausschreibungs-Radar — Einstellungen {t('settings.title')}
</h1> </h1>
<SourceConfigForm /> <SourceConfigForm />
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4"> <h2 className="text-lg font-semibold text-foreground mb-4">
RSS-Feeds {t('settings.rssSectionTitle')}
</h2> </h2>
<p className="mb-4 text-xs text-muted-foreground"> <p className="mb-4 text-xs text-muted-foreground">
Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder {t('settings.rssSectionBody')}
eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten
gleich, nicht pro Mandant konfigurierbar.
</p> </p>
<RssFeedListForm /> <RssFeedListForm />
</div> </div>
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4"> <h2 className="text-lg font-semibold text-foreground mb-4">
E-Mail-Alerts {t('settings.emailSectionTitle')}
</h2> </h2>
<p className="mb-4 text-xs text-muted-foreground"> <p className="mb-4 text-xs text-muted-foreground">
Postfach, in das Vergabeportale Ihre Benachrichtigungsmails {t('settings.emailSectionBody')}
schicken — pro Mandant konfigurierbar, getrennt vom
DKV-Rechnungspostfach. Aus diesen Mails erkannte Ausschreibungen
sind nur für Ihren Mandanten sichtbar.
</p> </p>
<EmailAlertConfigForm /> <EmailAlertConfigForm />
</div> </div>
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4"> <h2 className="text-lg font-semibold text-foreground mb-4">
Benachrichtigungen {t('settings.notificationsSectionTitle')}
</h2> </h2>
{isLoading ? ( {isLoading ? (
@@ -132,7 +125,7 @@ export default function TenderRadarSettingsPage() {
htmlFor="tr-digest-interval" htmlFor="tr-digest-interval"
className="mb-1 block text-sm text-foreground" className="mb-1 block text-sm text-foreground"
> >
Digest-Intervall {t('settings.digestIntervalLabel')}
</label> </label>
<select <select
id="tr-digest-interval" id="tr-digest-interval"
@@ -141,22 +134,19 @@ export default function TenderRadarSettingsPage() {
disabled={isSaving} disabled={isSaving}
className="h-9 max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground" className="h-9 max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
> >
<option value="daily">Täglich</option> <option value="daily">{t('settings.digestDaily')}</option>
<option value="weekly">Wöchentlich</option> <option value="weekly">{t('settings.digestWeekly')}</option>
<option value="off">Aus</option> <option value="off">{t('settings.digestOff')}</option>
</select> </select>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Fasst neue Treffer aus Ihren Suchprofilen in einer täglichen {t('settings.digestHelp')}
oder wöchentlichen Sammel-Mail zusammen. „Aus" deaktiviert den
Digest — Sofort-Alerts pro Suchprofil bleiben unabhängig davon
nutzbar.
</p> </p>
</div> </div>
)} )}
{saveSuccess && ( {saveSuccess && (
<p className="mt-2 text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}> <p className="mt-2 text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellung gespeichert. {t('settings.saveSuccess')}
</p> </p>
)} )}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>} {error && <p className="mt-2 text-sm text-destructive">{error}</p>}