diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx new file mode 100644 index 0000000..20d0eba --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx @@ -0,0 +1,510 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { + type DkvConfig, + fetchConfig, + saveConfig, + testConnection, +} from '@/lib/dkv-api'; + +/** Inline eye SVG (16×16) for show/hide password toggle */ +function EyeIcon() { + return ( + + ); +} + +/** Inline eye-off SVG (16×16) for show/hide password toggle */ +function EyeOffIcon() { + return ( + + ); +} + +/** + * Form state — includes the password field (optional, only sent when user types a new one). + * T-07-12: password is never pre-filled from server data; server only returns hasPassword. + */ +interface FormState { + protocol: 'imap' | 'exchange'; + host: string; + port: string; + encryption: 'none' | 'starttls' | 'ssl-tls'; + folder: string; + senderFilter: string; + pollIntervalMin: string; + username: string; + password: string; // intentionally blank on load (T-07-12) + exportRecipient: string; + vehicleFormatString: string; + isActive: boolean; +} + +const DEFAULT_FORM: FormState = { + protocol: 'imap', + host: '', + port: '993', + encryption: 'ssl-tls', + folder: 'INBOX', + senderFilter: '', + pollIntervalMin: '30', + username: '', + password: '', // blank — T-07-12 + exportRecipient: '', + vehicleFormatString: '{Marke}/{Modell}/{Kennzeichen}', + isActive: false, +}; + +function configToForm(config: DkvConfig): FormState { + return { + protocol: config.protocol, + host: config.host, + port: String(config.port), + encryption: config.encryption, + folder: config.folder, + senderFilter: config.senderFilter ?? '', + pollIntervalMin: String(config.pollIntervalMin), + username: config.username ?? '', + password: '', // NEVER pre-fill from server — T-07-12 + exportRecipient: config.exportRecipient, + vehicleFormatString: config.vehicleFormatString, + isActive: config.isActive, + }; +} + +function formToPayload( + form: FormState, +): Partial & { password?: string } { + const payload: Partial & { password?: string } = { + protocol: form.protocol, + host: form.host, + port: Number(form.port), + encryption: form.encryption, + folder: form.folder, + senderFilter: form.senderFilter || undefined, + pollIntervalMin: Number(form.pollIntervalMin), + username: form.username || undefined, + exportRecipient: form.exportRecipient, + vehicleFormatString: form.vehicleFormatString, + isActive: form.isActive, + }; + // Only include password when user typed a new one (T-07-12) + if (form.password) { + payload.password = form.password; + } + return payload; +} + +/** + * InboxConfigForm — Surface B Tab 1: Posteingang configuration. + * Fields: Protokoll, Host, Port, Verschlüsselung, Ordner, Absenderfilter, + * Abrufintervall, Benutzername, Passwort (show/hide), Export-Empfänger, + * Fahrzeug-Formatstring, Aktiv toggle. + * Security: T-07-12 — password never pre-filled from server. + */ +export function InboxConfigForm() { + const t = useTranslations('dkvFleet'); + + const [form, setForm] = useState(DEFAULT_FORM); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + + // Test feedback state + const [testResult, setTestResult] = useState<{ + success: boolean; + message?: string; + } | null>(null); + + // Save feedback state + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + + // Load config on mount + useEffect(() => { + fetchConfig() + .then((config) => { + setForm(configToForm(config)); + }) + .catch(() => { + // Keep defaults if config not yet created + }) + .finally(() => { + setIsLoading(false); + }); + }, []); + + const update = useCallback( + (key: keyof FormState, value: string | boolean) => { + setForm((f) => ({ ...f, [key]: value })); + setTestResult(null); + setSaveError(null); + setSaveSuccess(false); + }, + [], + ); + + const handleTestConnection = async () => { + setIsTesting(true); + setTestResult(null); + try { + const result = await testConnection(formToPayload(form)); + setTestResult(result); + } catch (err) { + setTestResult({ + success: false, + message: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen', + }); + } finally { + setIsTesting(false); + } + }; + + const handleSave = async () => { + setIsSaving(true); + setSaveError(null); + setSaveSuccess(false); + try { + await saveConfig(formToPayload(form)); + setSaveSuccess(true); + // Clear password field after save (T-07-12) + setForm((f) => ({ ...f, password: '' })); + } catch (err) { + setSaveError( + err instanceof Error ? err.message : 'Einstellungen konnten nicht gespeichert werden', + ); + } finally { + setIsSaving(false); + } + }; + + const inputCls = + 'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground'; + const labelCls = 'mb-1 block text-sm text-foreground'; + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+ {/* Protokoll */} +
+ + +
+ + {/* Host */} +
+ + update('host', e.target.value)} + /> +
+ + {/* Port */} +
+ + update('port', e.target.value)} + /> +
+ + {/* Verschlüsselung */} +
+ + +
+ + {/* Ordner */} +
+ + update('folder', e.target.value)} + /> +
+ + {/* Absenderfilter */} +
+ + update('senderFilter', e.target.value)} + /> +
+ + {/* Abrufintervall */} +
+ +
+ update('pollIntervalMin', e.target.value)} + /> + + {t('form.pollIntervalSuffix')} + +
+
+ + {/* Benutzername */} +
+ + update('username', e.target.value)} + /> +
+ + {/* Passwort — T-07-12: never pre-filled from server */} +
+ +
+ update('password', e.target.value)} + /> + +
+
+ + {/* Export-Empfänger */} +
+ + update('exportRecipient', e.target.value)} + /> +
+ + {/* Fahrzeug-Formatstring */} +
+ + update('vehicleFormatString', e.target.value)} + /> +

+ {t('form.vehicleFormatHelp')} +

+
+ + {/* Aktiv — toggle switch per UI-SPEC */} +
+ + +
+ + {/* Form actions row per UI-SPEC */} +
+ + +
+ + {/* Inline test feedback */} + {testResult !== null && ( +

+ {testResult.success + ? t('form.testSuccess') + : t('form.testFailed', { + error: testResult.message ?? 'Unbekannter Fehler', + })} +

+ )} + + {/* Inline save feedback */} + {saveSuccess && ( +

+ Einstellungen gespeichert. +

+ )} + {saveError && ( +

{saveError}

+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx new file mode 100644 index 0000000..a581aab --- /dev/null +++ b/apps/web/src/app/(portal)/modules/dkv-fleet/settings/page.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { InboxConfigForm } from './components/InboxConfigForm'; + +type ActiveTab = 'inbox' | 'vehicles'; + +/** + * DKV Fleet Module Settings Page (Surface B from 07-UI-SPEC). + * Renders a tab bar (Posteingang / Fahrzeuge) with InboxConfigForm on Tab 1. + * Tab 2 navigates to /modules/dkv-fleet/vehicles (separate route per plan split). + */ +export default function DkvFleetSettingsPage() { + const t = useTranslations('dkvFleet'); + const [activeTab, setActiveTab] = useState('inbox'); + + const tabCls = (tab: ActiveTab) => + `px-4 py-2 text-sm transition-colors ${ + activeTab === tab + ? 'border-b-2 border-primary text-foreground font-medium' + : 'text-muted-foreground hover:text-foreground' + }`; + + return ( +
+

+ {t('settingsTitle')} +

+ + {/* Tab bar per UI-SPEC */} +
+
+ + {/* Vehicles tab navigates to its own route */} + + {t('tabs.vehicles')} + +
+
+ + {/* Tab 1: Posteingang → InboxConfigForm */} + {activeTab === 'inbox' && } +
+ ); +}