feat: restructure settings menu + add automation/agents to topbar
TopBar: - Add Automation and Agenten to user dropdown menu Settings restructure: - Stammdaten: Firmendaten, Adressen (CRUD), Konten (CRUD), Sonstige Daten - Nutzerverwaltung: Benutzer, Rollen, Gruppen as tabs - System: Menü, Theme, Plugins as tabs - Reduce nav items from 15+ to 8 - Add end prop to all settings NavLinks
This commit is contained in:
@@ -8,7 +8,7 @@ import { useLogout } from '@/api/hooks';
|
|||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||||
import { Building, ChevronDown, Menu } from 'lucide-react';
|
import { Building, ChevronDown, Menu, Zap, Bot } from 'lucide-react';
|
||||||
|
|
||||||
export function TopBar() {
|
export function TopBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -116,6 +116,22 @@ export function TopBar() {
|
|||||||
>
|
>
|
||||||
{t('topbar.settings')}
|
{t('topbar.settings')}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setUserMenuOpen(false); navigate('/automation'); }}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 flex items-center gap-2"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
|
{t('nav.automation', 'Automation')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setUserMenuOpen(false); navigate('/agents'); }}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 flex items-center gap-2"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Bot className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
|
{t('nav.agents', 'Agenten')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setUserMenuOpen(false); navigate('/audit-log'); }}
|
onClick={() => { setUserMenuOpen(false); navigate('/audit-log'); }}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-secondary-50 min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
|
|||||||
@@ -15,21 +15,14 @@ export function SettingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hardcodedNavItems = [
|
const hardcodedNavItems = [
|
||||||
{ to: '/settings/system', label: t('systemSettings.title'), icon: '\u2699\ufe0f' },
|
{ to: '/settings/stammdaten', label: 'Stammdaten', icon: '\ud83c\udfe2' },
|
||||||
{ to: '/settings/roles', label: t('settings.roles'), icon: '\ud83d\udd11' },
|
{ to: '/settings/user-management', label: 'Nutzerverwaltung', icon: '\ud83d\udc65' },
|
||||||
{ to: '/settings/users', label: t('settings.users'), icon: '\ud83d\udc65' },
|
{ to: '/settings/system', label: 'System', icon: '\u2699\ufe0f' },
|
||||||
{ to: '/settings/groups', label: t('settings.groups', 'Gruppen'), icon: '\ud83d\udc65\ud83d\udcac' },
|
|
||||||
{ to: '/settings/plugins', label: t('settings.plugins'), icon: '\ud83e\udde9' },
|
|
||||||
{ to: '/settings/currencies', label: t('currencies.title'), icon: '\ud83d\udcb0' },
|
|
||||||
{ to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' },
|
|
||||||
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
|
|
||||||
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
|
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
|
||||||
{ to: '/settings/ai', label: t('nav.aiAssistant'), icon: '\ud83e\udde0' },
|
{ to: '/settings/ai', label: t('nav.aiAssistant'), icon: '\ud83e\udde0' },
|
||||||
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
|
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
|
||||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||||
{ to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' },
|
|
||||||
{ to: '/settings/mcp', label: t('settings.mcp', 'MCP'), icon: '\ud83d\udd27' },
|
{ to: '/settings/mcp', label: t('settings.mcp', 'MCP'), icon: '\ud83d\udd27' },
|
||||||
{ to: '/settings/menu', label: t('settings.menuOrder', 'Menü'), icon: '\ud83d\udccb' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
|
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
|
||||||
@@ -55,6 +48,7 @@ export function SettingsPage() {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
end
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
useSystemSettings,
|
||||||
|
useUpdateSystemSettings,
|
||||||
|
useCurrencies,
|
||||||
|
useTaxes,
|
||||||
|
} from '@/api/hooks';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
company_name: z.string().min(1, 'Name ist erforderlich').max(200),
|
||||||
|
company_legal_form: z.string().max(50).optional().or(z.literal('')),
|
||||||
|
company_street: z.string().min(1, 'Straße ist erforderlich').max(255),
|
||||||
|
company_city: z.string().min(1, 'Stadt ist erforderlich').max(100),
|
||||||
|
company_zip: z.string().min(1, 'PLZ ist erforderlich').max(20),
|
||||||
|
company_country: z.string().min(2, 'Land ist erforderlich').max(2),
|
||||||
|
tax_number: z.string().max(50).optional().or(z.literal('')),
|
||||||
|
vat_id: z.string().max(50).optional().or(z.literal('')),
|
||||||
|
iban: z.string().max(34).optional().or(z.literal('')),
|
||||||
|
bic: z.string().max(11).optional().or(z.literal('')),
|
||||||
|
bank_name: z.string().max(100).optional().or(z.literal('')),
|
||||||
|
ceo: z.string().max(100).optional().or(z.literal('')),
|
||||||
|
trade_register: z.string().max(100).optional().or(z.literal('')),
|
||||||
|
default_currency_id: z.string().optional().or(z.literal('')),
|
||||||
|
default_tax_id: z.string().optional().or(z.literal('')),
|
||||||
|
invoice_prefix: z.string().max(20).optional().or(z.literal('RE-')),
|
||||||
|
quote_prefix: z.string().max(20).optional().or(z.literal('AN-')),
|
||||||
|
payment_terms_days: z.number().int().min(0).max(365).default(14),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||||
|
|
||||||
|
export function SettingsFirmendatenPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useSystemSettings();
|
||||||
|
const { data: currenciesData } = useCurrencies();
|
||||||
|
const { data: taxesData } = useTaxes();
|
||||||
|
const updateMutation = useUpdateSystemSettings();
|
||||||
|
|
||||||
|
const currencies = currenciesData?.items ?? [];
|
||||||
|
const taxes = taxesData?.items ?? [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isDirty, isSubmitting },
|
||||||
|
} = useForm<SettingsFormValues>({
|
||||||
|
resolver: zodResolver(settingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
company_name: '',
|
||||||
|
company_legal_form: '',
|
||||||
|
company_street: '',
|
||||||
|
company_city: '',
|
||||||
|
company_zip: '',
|
||||||
|
company_country: 'DE',
|
||||||
|
tax_number: '',
|
||||||
|
vat_id: '',
|
||||||
|
iban: '',
|
||||||
|
bic: '',
|
||||||
|
bank_name: '',
|
||||||
|
ceo: '',
|
||||||
|
trade_register: '',
|
||||||
|
default_currency_id: '',
|
||||||
|
default_tax_id: '',
|
||||||
|
invoice_prefix: 'RE-',
|
||||||
|
quote_prefix: 'AN-',
|
||||||
|
payment_terms_days: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings && Object.keys(settings).length > 0) {
|
||||||
|
reset({
|
||||||
|
company_name: settings.company_name || '',
|
||||||
|
company_legal_form: settings.company_legal_form || '',
|
||||||
|
company_street: settings.company_street || '',
|
||||||
|
company_city: settings.company_city || '',
|
||||||
|
company_zip: settings.company_zip || '',
|
||||||
|
company_country: settings.company_country || 'DE',
|
||||||
|
tax_number: settings.tax_number || '',
|
||||||
|
vat_id: settings.vat_id || '',
|
||||||
|
iban: settings.iban || '',
|
||||||
|
bic: settings.bic || '',
|
||||||
|
bank_name: settings.bank_name || '',
|
||||||
|
ceo: settings.ceo || '',
|
||||||
|
trade_register: settings.trade_register || '',
|
||||||
|
default_currency_id: settings.default_currency_id || '',
|
||||||
|
default_tax_id: settings.default_tax_id || '',
|
||||||
|
invoice_prefix: settings.invoice_prefix || 'RE-',
|
||||||
|
quote_prefix: settings.quote_prefix || 'AN-',
|
||||||
|
payment_terms_days: settings.payment_terms_days || 14,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (values: SettingsFormValues) => {
|
||||||
|
try {
|
||||||
|
const payload = { ...values };
|
||||||
|
if (!payload.default_currency_id) payload.default_currency_id = null as any;
|
||||||
|
if (!payload.default_tax_id) payload.default_tax_id = null as any;
|
||||||
|
await updateMutation.mutateAsync(payload);
|
||||||
|
toast.success(t('systemSettings.saved'));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || t('systemSettings.saveFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" data-testid="settings-system-page">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-96" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="settings-system-page">
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">{t('systemSettings.title')}</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||||
|
<Card title={t('systemSettings.companyInfo')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
{...register('company_name')}
|
||||||
|
label={t('systemSettings.companyName')}
|
||||||
|
required
|
||||||
|
error={errors.company_name?.message}
|
||||||
|
data-testid="settings-company-name"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('company_legal_form')}
|
||||||
|
label={t('systemSettings.legalForm')}
|
||||||
|
error={errors.company_legal_form?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('systemSettings.companyAddress')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
{...register('company_street')}
|
||||||
|
label={t('address.street')}
|
||||||
|
required
|
||||||
|
error={errors.company_street?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('company_zip')}
|
||||||
|
label={t('address.zip')}
|
||||||
|
required
|
||||||
|
error={errors.company_zip?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('company_city')}
|
||||||
|
label={t('address.city')}
|
||||||
|
required
|
||||||
|
error={errors.company_city?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('company_country')}
|
||||||
|
label={t('address.country')}
|
||||||
|
required
|
||||||
|
error={errors.company_country?.message}
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('systemSettings.taxBankInfo')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
{...register('tax_number')}
|
||||||
|
label={t('systemSettings.taxNumber')}
|
||||||
|
error={errors.tax_number?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('vat_id')}
|
||||||
|
label={t('systemSettings.vatId')}
|
||||||
|
error={errors.vat_id?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('iban')}
|
||||||
|
label={t('systemSettings.iban')}
|
||||||
|
error={errors.iban?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('bic')}
|
||||||
|
label={t('systemSettings.bic')}
|
||||||
|
error={errors.bic?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('bank_name')}
|
||||||
|
label={t('systemSettings.bankName')}
|
||||||
|
error={errors.bank_name?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('ceo')}
|
||||||
|
label={t('systemSettings.ceo')}
|
||||||
|
error={errors.ceo?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('trade_register')}
|
||||||
|
label={t('systemSettings.tradeRegister')}
|
||||||
|
error={errors.trade_register?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('systemSettings.defaults')}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-secondary-700">
|
||||||
|
{t('systemSettings.defaultCurrency')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('default_currency_id')}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{currencies.map((c: any) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.code} — {c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium text-secondary-700">
|
||||||
|
{t('systemSettings.defaultTax')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('default_tax_id')}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{taxes.map((t: any) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name} ({t.rate}%)</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
{...register('invoice_prefix')}
|
||||||
|
label={t('systemSettings.invoicePrefix')}
|
||||||
|
error={errors.invoice_prefix?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('quote_prefix')}
|
||||||
|
label={t('systemSettings.quotePrefix')}
|
||||||
|
error={errors.quote_prefix?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
{...register('payment_terms_days', { valueAsNumber: true })}
|
||||||
|
label={t('systemSettings.paymentTermsDays')}
|
||||||
|
type="number"
|
||||||
|
error={errors.payment_terms_days?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button type="submit" isLoading={isSubmitting} data-testid="settings-system-save">
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SettingsFirmendatenPage } from './SettingsFirmendaten';
|
||||||
|
import { SettingsCurrenciesPage } from './SettingsCurrencies';
|
||||||
|
import { SettingsTaxesPage } from './SettingsTaxes';
|
||||||
|
import { SettingsSequencesPage } from './SettingsSequences';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Table, TableColumn } from '@/components/ui/Table';
|
||||||
|
import { Plus, Trash2, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
// --- Adressen Tab ---
|
||||||
|
interface Address {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
street: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addressTypeOptions = [
|
||||||
|
{ value: 'standort', label: 'Standort' },
|
||||||
|
{ value: 'rechnung', label: 'Rechnungsadresse' },
|
||||||
|
{ value: 'lieferung', label: 'Lieferadresse' },
|
||||||
|
{ value: 'eigene', label: 'Eigene Bezeichnung' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AdressenTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||||
|
const [editing, setEditing] = useState<Address | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditing({ id: '', type: 'standort', street: '', zip: '', city: '', country: 'DE', label: '' });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (addr: Address) => {
|
||||||
|
setEditing({ ...addr });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setAddresses(addresses.filter(a => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!editing) return;
|
||||||
|
if (editing.id) {
|
||||||
|
setAddresses(addresses.map(a => a.id === editing.id ? editing : a));
|
||||||
|
} else {
|
||||||
|
setAddresses([...addresses, { ...editing, id: Date.now().toString() }]);
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumn<Address>[] = [
|
||||||
|
{ key: 'type', header: 'Art', render: (row) => addressTypeOptions.find(o => o.value === row.type)?.label || row.type },
|
||||||
|
{ key: 'street', header: t('address.street', 'Straße') },
|
||||||
|
{ key: 'zip', header: t('address.zip', 'PLZ') },
|
||||||
|
{ key: 'city', header: t('address.city', 'Stadt') },
|
||||||
|
{ key: 'country', header: t('address.country', 'Land') },
|
||||||
|
{ key: 'label', header: 'Bezeichnung', render: (row) => row.label || '—' },
|
||||||
|
{
|
||||||
|
key: 'actions', header: '', render: (row) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card title="Adressverwaltung" actions={
|
||||||
|
<Button size="sm" onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t('common.add', 'Hinzufügen')}
|
||||||
|
</Button>
|
||||||
|
}>
|
||||||
|
<Table columns={columns} data={addresses} rowKey={(row) => row.id} emptyMessage="Noch keine Adressen angelegt" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showForm && editing && (
|
||||||
|
<Card title={editing.id ? 'Adresse bearbeiten' : 'Neue Adresse'}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
label="Art"
|
||||||
|
options={addressTypeOptions}
|
||||||
|
value={editing.type}
|
||||||
|
onChange={(e) => setEditing({ ...editing, type: e.target.value })}
|
||||||
|
/>
|
||||||
|
{editing.type === 'eigene' && (
|
||||||
|
<Input
|
||||||
|
label="Eigene Bezeichnung"
|
||||||
|
value={editing.label}
|
||||||
|
onChange={(e) => setEditing({ ...editing, label: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
label={t('address.street', 'Straße')}
|
||||||
|
value={editing.street}
|
||||||
|
onChange={(e) => setEditing({ ...editing, street: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('address.zip', 'PLZ')}
|
||||||
|
value={editing.zip}
|
||||||
|
onChange={(e) => setEditing({ ...editing, zip: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('address.city', 'Stadt')}
|
||||||
|
value={editing.city}
|
||||||
|
onChange={(e) => setEditing({ ...editing, city: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label={t('address.country', 'Land')}
|
||||||
|
value={editing.country}
|
||||||
|
maxLength={2}
|
||||||
|
onChange={(e) => setEditing({ ...editing, country: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-4">
|
||||||
|
<Button variant="secondary" onClick={() => { setShowForm(false); setEditing(null); }}>
|
||||||
|
{t('common.cancel', 'Abbrechen')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{t('common.save', 'Speichern')}</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Konten Tab ---
|
||||||
|
interface BankAccount {
|
||||||
|
id: string;
|
||||||
|
bankName: string;
|
||||||
|
iban: string;
|
||||||
|
bic: string;
|
||||||
|
accountHolder: string;
|
||||||
|
defaultTax: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KontenTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [accounts, setAccounts] = useState<BankAccount[]>([]);
|
||||||
|
const [editing, setEditing] = useState<BankAccount | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditing({ id: '', bankName: '', iban: '', bic: '', accountHolder: '', defaultTax: '' });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (acc: BankAccount) => {
|
||||||
|
setEditing({ ...acc });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
setAccounts(accounts.filter(a => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!editing) return;
|
||||||
|
if (editing.id) {
|
||||||
|
setAccounts(accounts.map(a => a.id === editing.id ? editing : a));
|
||||||
|
} else {
|
||||||
|
setAccounts([...accounts, { ...editing, id: Date.now().toString() }]);
|
||||||
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumn<BankAccount>[] = [
|
||||||
|
{ key: 'bankName', header: 'Bankname' },
|
||||||
|
{ key: 'iban', header: 'IBAN' },
|
||||||
|
{ key: 'bic', header: 'BIC' },
|
||||||
|
{ key: 'accountHolder', header: 'Kontoinhaber' },
|
||||||
|
{ key: 'defaultTax', header: 'Standard-Steuer', render: (row) => row.defaultTax || '—' },
|
||||||
|
{
|
||||||
|
key: 'actions', header: '', render: (row) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card title="Bankkontenverwaltung" actions={
|
||||||
|
<Button size="sm" onClick={handleAdd}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t('common.add', 'Hinzufügen')}
|
||||||
|
</Button>
|
||||||
|
}>
|
||||||
|
<Table columns={columns} data={accounts} rowKey={(row) => row.id} emptyMessage="Noch keine Konten angelegt" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showForm && editing && (
|
||||||
|
<Card title={editing.id ? 'Konto bearbeiten' : 'Neues Konto'}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Bankname"
|
||||||
|
value={editing.bankName}
|
||||||
|
onChange={(e) => setEditing({ ...editing, bankName: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="IBAN"
|
||||||
|
value={editing.iban}
|
||||||
|
onChange={(e) => setEditing({ ...editing, iban: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="BIC"
|
||||||
|
value={editing.bic}
|
||||||
|
onChange={(e) => setEditing({ ...editing, bic: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Kontoinhaber"
|
||||||
|
value={editing.accountHolder}
|
||||||
|
onChange={(e) => setEditing({ ...editing, accountHolder: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Standard-Steuer"
|
||||||
|
value={editing.defaultTax}
|
||||||
|
onChange={(e) => setEditing({ ...editing, defaultTax: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 mt-4">
|
||||||
|
<Button variant="secondary" onClick={() => { setShowForm(false); setEditing(null); }}>
|
||||||
|
{t('common.cancel', 'Abbrechen')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{t('common.save', 'Speichern')}</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sonstige Daten Tab ---
|
||||||
|
function SonstigeDatenTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCurrenciesPage />
|
||||||
|
<SettingsTaxesPage />
|
||||||
|
<SettingsSequencesPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main Stammdaten Page ---
|
||||||
|
export function SettingsStammdatenPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState('firmendaten');
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'firmendaten', label: 'Firmendaten' },
|
||||||
|
{ key: 'adressen', label: 'Adressen' },
|
||||||
|
{ key: 'konten', label: 'Konten' },
|
||||||
|
{ key: 'sonstige', label: 'Sonstige Daten' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="settings-stammdaten-page">
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">Stammdaten</h1>
|
||||||
|
|
||||||
|
<div className="border-b border-secondary-200">
|
||||||
|
<nav className="flex gap-4" role="tablist" aria-label="Stammdaten Tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary-500 text-primary-700'
|
||||||
|
: 'border-transparent text-secondary-600 hover:text-secondary-900 hover:border-secondary-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tabpanel">
|
||||||
|
{activeTab === 'firmendaten' && <SettingsFirmendatenPage />}
|
||||||
|
{activeTab === 'adressen' && <AdressenTab />}
|
||||||
|
{activeTab === 'konten' && <KontenTab />}
|
||||||
|
{activeTab === 'sonstige' && <SonstigeDatenTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,277 +1,48 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useForm } from 'react-hook-form';
|
import { SettingsMenuOrderPage } from './SettingsMenuOrder';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { SettingsThemePage } from './SettingsTheme';
|
||||||
import { z } from 'zod';
|
import { SettingsPluginsPage } from './SettingsPlugins';
|
||||||
import {
|
|
||||||
useSystemSettings,
|
|
||||||
useUpdateSystemSettings,
|
|
||||||
useCurrencies,
|
|
||||||
useTaxes,
|
|
||||||
} from '@/api/hooks';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
|
||||||
import { useToast } from '@/components/ui/Toast';
|
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
|
||||||
company_name: z.string().min(1, 'Name ist erforderlich').max(200),
|
|
||||||
company_legal_form: z.string().max(50).optional().or(z.literal('')),
|
|
||||||
company_street: z.string().min(1, 'Straße ist erforderlich').max(255),
|
|
||||||
company_city: z.string().min(1, 'Stadt ist erforderlich').max(100),
|
|
||||||
company_zip: z.string().min(1, 'PLZ ist erforderlich').max(20),
|
|
||||||
company_country: z.string().min(2, 'Land ist erforderlich').max(2),
|
|
||||||
tax_number: z.string().max(50).optional().or(z.literal('')),
|
|
||||||
vat_id: z.string().max(50).optional().or(z.literal('')),
|
|
||||||
iban: z.string().max(34).optional().or(z.literal('')),
|
|
||||||
bic: z.string().max(11).optional().or(z.literal('')),
|
|
||||||
bank_name: z.string().max(100).optional().or(z.literal('')),
|
|
||||||
ceo: z.string().max(100).optional().or(z.literal('')),
|
|
||||||
trade_register: z.string().max(100).optional().or(z.literal('')),
|
|
||||||
default_currency_id: z.string().optional().or(z.literal('')),
|
|
||||||
default_tax_id: z.string().optional().or(z.literal('')),
|
|
||||||
invoice_prefix: z.string().max(20).optional().or(z.literal('RE-')),
|
|
||||||
quote_prefix: z.string().max(20).optional().or(z.literal('AN-')),
|
|
||||||
payment_terms_days: z.number().int().min(0).max(365).default(14),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
|
||||||
|
|
||||||
export function SettingsSystemPage() {
|
export function SettingsSystemPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const [activeTab, setActiveTab] = useState('menu');
|
||||||
|
|
||||||
const { data: settings, isLoading } = useSystemSettings();
|
const tabs = [
|
||||||
const { data: currenciesData } = useCurrencies();
|
{ key: 'menu', label: t('settings.menuOrder', 'Menü') },
|
||||||
const { data: taxesData } = useTaxes();
|
{ key: 'theme', label: t('settings.theme', 'Theme') },
|
||||||
const updateMutation = useUpdateSystemSettings();
|
{ key: 'plugins', label: t('settings.plugins', 'Plugins') },
|
||||||
|
];
|
||||||
const currencies = currenciesData?.items ?? [];
|
|
||||||
const taxes = taxesData?.items ?? [];
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { errors, isDirty, isSubmitting },
|
|
||||||
} = useForm<SettingsFormValues>({
|
|
||||||
resolver: zodResolver(settingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
company_name: '',
|
|
||||||
company_legal_form: '',
|
|
||||||
company_street: '',
|
|
||||||
company_city: '',
|
|
||||||
company_zip: '',
|
|
||||||
company_country: 'DE',
|
|
||||||
tax_number: '',
|
|
||||||
vat_id: '',
|
|
||||||
iban: '',
|
|
||||||
bic: '',
|
|
||||||
bank_name: '',
|
|
||||||
ceo: '',
|
|
||||||
trade_register: '',
|
|
||||||
default_currency_id: '',
|
|
||||||
default_tax_id: '',
|
|
||||||
invoice_prefix: 'RE-',
|
|
||||||
quote_prefix: 'AN-',
|
|
||||||
payment_terms_days: 14,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings && Object.keys(settings).length > 0) {
|
|
||||||
reset({
|
|
||||||
company_name: settings.company_name || '',
|
|
||||||
company_legal_form: settings.company_legal_form || '',
|
|
||||||
company_street: settings.company_street || '',
|
|
||||||
company_city: settings.company_city || '',
|
|
||||||
company_zip: settings.company_zip || '',
|
|
||||||
company_country: settings.company_country || 'DE',
|
|
||||||
tax_number: settings.tax_number || '',
|
|
||||||
vat_id: settings.vat_id || '',
|
|
||||||
iban: settings.iban || '',
|
|
||||||
bic: settings.bic || '',
|
|
||||||
bank_name: settings.bank_name || '',
|
|
||||||
ceo: settings.ceo || '',
|
|
||||||
trade_register: settings.trade_register || '',
|
|
||||||
default_currency_id: settings.default_currency_id || '',
|
|
||||||
default_tax_id: settings.default_tax_id || '',
|
|
||||||
invoice_prefix: settings.invoice_prefix || 'RE-',
|
|
||||||
quote_prefix: settings.quote_prefix || 'AN-',
|
|
||||||
payment_terms_days: settings.payment_terms_days || 14,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [settings, reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (values: SettingsFormValues) => {
|
|
||||||
try {
|
|
||||||
const payload = { ...values };
|
|
||||||
if (!payload.default_currency_id) payload.default_currency_id = null as any;
|
|
||||||
if (!payload.default_tax_id) payload.default_tax_id = null as any;
|
|
||||||
await updateMutation.mutateAsync(payload);
|
|
||||||
toast.success(t('systemSettings.saved'));
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err.message || t('systemSettings.saveFailed'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4" data-testid="settings-system-page">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-96" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" data-testid="settings-system-page">
|
<div className="space-y-6" data-testid="settings-system-page">
|
||||||
<h1 className="text-2xl font-bold text-secondary-900">{t('systemSettings.title')}</h1>
|
<h1 className="text-2xl font-bold text-secondary-900">System</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
<div className="border-b border-secondary-200">
|
||||||
<Card title={t('systemSettings.companyInfo')}>
|
<nav className="flex gap-4" role="tablist" aria-label="System Tabs">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{tabs.map((tab) => (
|
||||||
<Input
|
<button
|
||||||
{...register('company_name')}
|
key={tab.key}
|
||||||
label={t('systemSettings.companyName')}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
required
|
role="tab"
|
||||||
error={errors.company_name?.message}
|
aria-selected={activeTab === tab.key}
|
||||||
data-testid="settings-company-name"
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
/>
|
activeTab === tab.key
|
||||||
<Input
|
? 'border-primary-500 text-primary-700'
|
||||||
{...register('company_legal_form')}
|
: 'border-transparent text-secondary-600 hover:text-secondary-900 hover:border-secondary-300'
|
||||||
label={t('systemSettings.legalForm')}
|
}`}
|
||||||
error={errors.company_legal_form?.message}
|
>
|
||||||
/>
|
{tab.label}
|
||||||
</div>
|
</button>
|
||||||
</Card>
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title={t('systemSettings.companyAddress')}>
|
<div role="tabpanel">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{activeTab === 'menu' && <SettingsMenuOrderPage />}
|
||||||
<Input
|
{activeTab === 'theme' && <SettingsThemePage />}
|
||||||
{...register('company_street')}
|
{activeTab === 'plugins' && <SettingsPluginsPage />}
|
||||||
label={t('address.street')}
|
</div>
|
||||||
required
|
|
||||||
error={errors.company_street?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('company_zip')}
|
|
||||||
label={t('address.zip')}
|
|
||||||
required
|
|
||||||
error={errors.company_zip?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('company_city')}
|
|
||||||
label={t('address.city')}
|
|
||||||
required
|
|
||||||
error={errors.company_city?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('company_country')}
|
|
||||||
label={t('address.country')}
|
|
||||||
required
|
|
||||||
error={errors.company_country?.message}
|
|
||||||
maxLength={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title={t('systemSettings.taxBankInfo')}>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
{...register('tax_number')}
|
|
||||||
label={t('systemSettings.taxNumber')}
|
|
||||||
error={errors.tax_number?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('vat_id')}
|
|
||||||
label={t('systemSettings.vatId')}
|
|
||||||
error={errors.vat_id?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('iban')}
|
|
||||||
label={t('systemSettings.iban')}
|
|
||||||
error={errors.iban?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('bic')}
|
|
||||||
label={t('systemSettings.bic')}
|
|
||||||
error={errors.bic?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('bank_name')}
|
|
||||||
label={t('systemSettings.bankName')}
|
|
||||||
error={errors.bank_name?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('ceo')}
|
|
||||||
label={t('systemSettings.ceo')}
|
|
||||||
error={errors.ceo?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('trade_register')}
|
|
||||||
label={t('systemSettings.tradeRegister')}
|
|
||||||
error={errors.trade_register?.message}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title={t('systemSettings.defaults')}>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm font-medium text-secondary-700">
|
|
||||||
{t('systemSettings.defaultCurrency')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
{...register('default_currency_id')}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
{currencies.map((c: any) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.code} — {c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-sm font-medium text-secondary-700">
|
|
||||||
{t('systemSettings.defaultTax')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
{...register('default_tax_id')}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
{taxes.map((t: any) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.name} ({t.rate}%)</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
{...register('invoice_prefix')}
|
|
||||||
label={t('systemSettings.invoicePrefix')}
|
|
||||||
error={errors.invoice_prefix?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('quote_prefix')}
|
|
||||||
label={t('systemSettings.quotePrefix')}
|
|
||||||
error={errors.quote_prefix?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
{...register('payment_terms_days', { valueAsNumber: true })}
|
|
||||||
label={t('systemSettings.paymentTermsDays')}
|
|
||||||
type="number"
|
|
||||||
error={errors.payment_terms_days?.message}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button type="submit" isLoading={isSubmitting} data-testid="settings-system-save">
|
|
||||||
{t('common.save')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SettingsUsersPage } from './SettingsUsers';
|
||||||
|
import { SettingsRolesPage } from './SettingsRoles';
|
||||||
|
import { SettingsGroupsPage } from './SettingsGroups';
|
||||||
|
|
||||||
|
export function SettingsUserManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [activeTab, setActiveTab] = useState('users');
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'users', label: t('settings.users', 'Benutzer') },
|
||||||
|
{ key: 'roles', label: t('settings.roles', 'Rollen') },
|
||||||
|
{ key: 'groups', label: t('settings.groups', 'Gruppen') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="settings-user-management-page">
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900">Nutzerverwaltung</h1>
|
||||||
|
|
||||||
|
<div className="border-b border-secondary-200">
|
||||||
|
<nav className="flex gap-4" role="tablist" aria-label="Nutzerverwaltung Tabs">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.key}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary-500 text-primary-700'
|
||||||
|
: 'border-transparent text-secondary-600 hover:text-secondary-900 hover:border-secondary-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tabpanel">
|
||||||
|
{activeTab === 'users' && <SettingsUsersPage />}
|
||||||
|
{activeTab === 'roles' && <SettingsRolesPage />}
|
||||||
|
{activeTab === 'groups' && <SettingsGroupsPage />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings
|
|||||||
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
||||||
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
|
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
|
||||||
const SettingsMenuOrderPage = React.lazy(() => import('@/pages/SettingsMenuOrder').then(m => ({ default: m.SettingsMenuOrderPage })));
|
const SettingsMenuOrderPage = React.lazy(() => import('@/pages/SettingsMenuOrder').then(m => ({ default: m.SettingsMenuOrderPage })));
|
||||||
|
const SettingsStammdatenPage = React.lazy(() => import('@/pages/SettingsStammdaten').then(m => ({ default: m.SettingsStammdatenPage })));
|
||||||
|
const SettingsUserManagementPage = React.lazy(() => import('@/pages/SettingsUserManagement').then(m => ({ default: m.SettingsUserManagementPage })));
|
||||||
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
|
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
|
||||||
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
||||||
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
|
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
|
||||||
@@ -99,6 +101,8 @@ const router = createBrowserRouter([
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
element: withSuspense(<SettingsPage />),
|
element: withSuspense(<SettingsPage />),
|
||||||
children: [
|
children: [
|
||||||
|
{ path: 'stammdaten', element: withSuspense(<SettingsStammdatenPage />) },
|
||||||
|
{ path: 'user-management', element: withSuspense(<SettingsUserManagementPage />) },
|
||||||
{ path: 'roles', element: withSuspense(<SettingsRolesPage />) },
|
{ path: 'roles', element: withSuspense(<SettingsRolesPage />) },
|
||||||
{ path: 'users', element: withSuspense(<SettingsUsersPage />) },
|
{ path: 'users', element: withSuspense(<SettingsUsersPage />) },
|
||||||
{ path: 'groups', element: withSuspense(<SettingsGroupsPage />) },
|
{ path: 'groups', element: withSuspense(<SettingsGroupsPage />) },
|
||||||
|
|||||||
Reference in New Issue
Block a user