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:
+40
@@ -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();
|
||||||
|
|||||||
+36
-32
@@ -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>}
|
||||||
|
|||||||
+32
@@ -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();
|
||||||
|
|||||||
+18
-21
@@ -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>
|
||||||
|
|||||||
+30
@@ -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
|
||||||
|
|||||||
+23
-19
@@ -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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user