feat(07-05): settings page + InboxConfigForm — Surface B inbox tab (DKV-01)
- settings/page.tsx: tab bar (Posteingang / Fahrzeuge), Vehicles tab links to /vehicles - InboxConfigForm: all UI-SPEC Tab-1 fields in order, show/hide password (T-07-12) - Active toggle: role=switch, pill shape, primary/muted colors - testConnection + saveConfig wired; inline green/destructive feedback - Password never pre-filled from server (hasPassword only) per T-07-12
This commit is contained in:
@@ -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 (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ellipse cx="8" cy="8" rx="6" ry="4" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inline eye-off SVG (16×16) for show/hide password toggle */
|
||||||
|
function EyeOffIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="2" y1="2" x2="14" y2="14" />
|
||||||
|
<path d="M6.5 5.2A6 6 0 0114 8a9 9 0 01-1.2 2M4.5 4.5A6 6 0 002 8a6 6 0 008 4.5" />
|
||||||
|
<circle cx="8" cy="8" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<DkvConfig> & { password?: string } {
|
||||||
|
const payload: Partial<DkvConfig> & { 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<FormState>(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<string | null>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 6 }).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">
|
||||||
|
{/* Protokoll */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-protocol" className={labelCls}>
|
||||||
|
{t('form.protocol')} *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="dkv-protocol"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.protocol}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('protocol', e.target.value as 'imap' | 'exchange')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="imap">IMAP</option>
|
||||||
|
<option value="exchange">Exchange</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Host */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-host" className={labelCls}>
|
||||||
|
{t('form.host')} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dkv-host"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className={inputCls}
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => update('host', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-port" className={labelCls}>
|
||||||
|
{t('form.port')} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dkv-port"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
className={inputCls}
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => update('port', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verschlüsselung */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-encryption" className={labelCls}>
|
||||||
|
{t('form.encryption')} *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="dkv-encryption"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.encryption}
|
||||||
|
onChange={(e) =>
|
||||||
|
update('encryption', e.target.value as 'none' | 'starttls' | 'ssl-tls')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">{t('form.encryptionNone')}</option>
|
||||||
|
<option value="starttls">STARTTLS</option>
|
||||||
|
<option value="ssl-tls">SSL-TLS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ordner */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-folder" className={labelCls}>
|
||||||
|
{t('form.folder')} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dkv-folder"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className={inputCls}
|
||||||
|
value={form.folder}
|
||||||
|
onChange={(e) => update('folder', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Absenderfilter */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-sender-filter" className={labelCls}>
|
||||||
|
{t('form.senderFilter')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dkv-sender-filter"
|
||||||
|
type="email"
|
||||||
|
placeholder="rechnung@dkv.com"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.senderFilter}
|
||||||
|
onChange={(e) => update('senderFilter', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Abrufintervall */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-poll-interval" className={labelCls}>
|
||||||
|
{t('form.pollInterval')} *
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="dkv-poll-interval"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={5}
|
||||||
|
className="h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground"
|
||||||
|
value={form.pollIntervalMin}
|
||||||
|
onChange={(e) => update('pollIntervalMin', e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t('form.pollIntervalSuffix')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benutzername */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-username" className={labelCls}>
|
||||||
|
{t('form.username')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dkv-username"
|
||||||
|
type="text"
|
||||||
|
className={inputCls}
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) => update('username', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Passwort — T-07-12: never pre-filled from server */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-password" className={labelCls}>
|
||||||
|
{t('form.password')}
|
||||||
|
</label>
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<input
|
||||||
|
id="dkv-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground"
|
||||||
|
value={form.password}
|
||||||
|
placeholder={
|
||||||
|
/* Hint that a password is stored server-side (without revealing it) */
|
||||||
|
'••••••••'
|
||||||
|
}
|
||||||
|
onChange={(e) => update('password', e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={
|
||||||
|
showPassword ? t('form.hidePassword') : t('form.showPassword')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export-Empfänger */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-export-recipient" className={labelCls}>
|
||||||
|
{t('form.exportRecipient')} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dkv-export-recipient"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className={inputCls}
|
||||||
|
value={form.exportRecipient}
|
||||||
|
onChange={(e) => update('exportRecipient', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fahrzeug-Formatstring */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dkv-vehicle-format" className={labelCls}>
|
||||||
|
{t('form.vehicleFormat')} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dkv-vehicle-format"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className={inputCls}
|
||||||
|
value={form.vehicleFormatString}
|
||||||
|
onChange={(e) => update('vehicleFormatString', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t('form.vehicleFormatHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aktiv — toggle switch per UI-SPEC */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label
|
||||||
|
htmlFor="dkv-active"
|
||||||
|
className="text-sm text-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{t('form.active')}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="dkv-active"
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={form.isActive}
|
||||||
|
onClick={() => update('isActive', !form.isActive)}
|
||||||
|
className="relative flex-shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 22,
|
||||||
|
background: form.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: form.isActive ? 'translateX(18px)' : 'translateX(0)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form actions row per UI-SPEC */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-border mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={isTesting || isSaving}
|
||||||
|
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isTesting ? t('form.testTesting') : t('form.testConnection')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || isTesting}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t('form.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline test feedback */}
|
||||||
|
{testResult !== null && (
|
||||||
|
<p
|
||||||
|
className="text-sm"
|
||||||
|
style={{
|
||||||
|
color: testResult.success
|
||||||
|
? 'oklch(0.40 0.15 148)'
|
||||||
|
: 'var(--destructive)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testResult.success
|
||||||
|
? t('form.testSuccess')
|
||||||
|
: t('form.testFailed', {
|
||||||
|
error: testResult.message ?? 'Unbekannter Fehler',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline save feedback */}
|
||||||
|
{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,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<ActiveTab>('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 (
|
||||||
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight mb-6">
|
||||||
|
{t('settingsTitle')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Tab bar per UI-SPEC */}
|
||||||
|
<div className="border-b border-border mb-6">
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tabCls('inbox')}
|
||||||
|
onClick={() => setActiveTab('inbox')}
|
||||||
|
>
|
||||||
|
{t('tabs.inbox')}
|
||||||
|
</button>
|
||||||
|
{/* Vehicles tab navigates to its own route */}
|
||||||
|
<Link
|
||||||
|
href="/modules/dkv-fleet/vehicles"
|
||||||
|
className={`px-4 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground`}
|
||||||
|
>
|
||||||
|
{t('tabs.vehicles')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab 1: Posteingang → InboxConfigForm */}
|
||||||
|
{activeTab === 'inbox' && <InboxConfigForm />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user