Phase 6.3: SettingsForms on RHF + Zod

- SettingsCurrencies: RHF+Zod (code required max 3, name required, symbol required max 5)
- SettingsTaxes: RHF+Zod (name required, rate numeric 0-100, country max 2)
- SettingsSequences: RHF+Zod (name required, padding numeric 1-10)
- SettingsUsers: RHF+Zod (name required, email valid, password min 8)
- SettingsRoles: RHF+Zod for create form (name required)
- SettingsGroups: RHF+Zod for create form (name required, description optional)
- Error display under each field
- Preserved all existing functionality: CRUD, permissions, members
- Added 2 validation tests for SettingsCurrencies
This commit is contained in:
Agent Zero
2026-07-24 00:28:11 +02:00
parent 2931a850c0
commit c334d02989
7 changed files with 306 additions and 109 deletions
+51 -14
View File
@@ -1,5 +1,8 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface Currency {
@@ -10,6 +13,16 @@ interface Currency {
is_default: boolean;
}
// ── Zod Schema ──
const currencySchema = z.object({
code: z.string().min(1, 'required').max(3, 'maxLength').toUpperCase(),
name: z.string().min(1, 'required'),
symbol: z.string().min(1, 'required').max(5, 'maxLength'),
is_default: z.boolean().default(false),
});
type CurrencyFormData = z.infer<typeof currencySchema>;
export function SettingsCurrenciesPage() {
const { t } = useTranslation();
const [currencies, setCurrencies] = useState<Currency[]>([]);
@@ -17,7 +30,16 @@ export function SettingsCurrenciesPage() {
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Currency | null>(null);
const [formData, setFormData] = useState({ code: '', name: '', symbol: '', is_default: false });
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<CurrencyFormData>({
resolver: zodResolver(currencySchema),
defaultValues: { code: '', name: '', symbol: '', is_default: false },
});
const fetchCurrencies = useCallback(async () => {
setLoading(true);
@@ -34,17 +56,16 @@ export function SettingsCurrenciesPage() {
React.useEffect(() => { fetchCurrencies(); }, [fetchCurrencies]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const onSubmit = async (data: CurrencyFormData) => {
try {
if (editing) {
await apiPatch(`/currencies/${editing.id}`, formData);
await apiPatch(`/currencies/${editing.id}`, data);
} else {
await apiPost('/currencies', formData);
await apiPost('/currencies', data);
}
setShowForm(false);
setEditing(null);
setFormData({ code: '', name: '', symbol: '', is_default: false });
reset({ code: '', name: '', symbol: '', is_default: false });
await fetchCurrencies();
} catch (err: any) {
setError(err.message || t('common.error'));
@@ -63,10 +84,23 @@ export function SettingsCurrenciesPage() {
const handleEdit = (c: Currency) => {
setEditing(c);
setFormData({ code: c.code, name: c.name, symbol: c.symbol, is_default: c.is_default });
reset({ code: c.code, name: c.name, symbol: c.symbol, is_default: c.is_default });
setShowForm(true);
};
const handleNew = () => {
setEditing(null);
reset({ code: '', name: '', symbol: '', is_default: false });
setShowForm(true);
};
const errorMsg = (key: string | undefined) => {
if (!key) return undefined;
if (key === 'required') return t('validation.required');
if (key === 'maxLength') return t('validation.maxLength', { count: key === 'maxLength' ? 5 : 3 });
return key;
};
if (loading) return <div className="text-secondary-500">{t('common.loading')}</div>;
return (
@@ -74,7 +108,7 @@ export function SettingsCurrenciesPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-secondary-900">{t('currencies.title')}</h1>
<button
onClick={() => { setEditing(null); setFormData({ code: '', name: '', symbol: '', is_default: false }); setShowForm(true); }}
onClick={handleNew}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
>
{t('currencies.add')}
@@ -84,28 +118,31 @@ export function SettingsCurrenciesPage() {
{error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{error}</div>}
{showForm && (
<form onSubmit={handleSave} className="p-4 border border-secondary-200 rounded-lg space-y-3">
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border border-secondary-200 rounded-lg space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('currencies.code')}</label>
<input type="text" value={formData.code} onChange={(e) => setFormData({ ...formData, code: e.target.value.toUpperCase() })} maxLength={3} required disabled={!!editing} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm disabled:bg-secondary-100" />
<input type="text" {...register('code')} maxLength={3} disabled={!!editing} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm disabled:bg-secondary-100" />
{errors.code && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.code.message)}</p>}
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('currencies.symbol')}</label>
<input type="text" value={formData.symbol} onChange={(e) => setFormData({ ...formData, symbol: e.target.value })} maxLength={5} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('symbol')} maxLength={5} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.symbol && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.symbol.message)}</p>}
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('currencies.name')}</label>
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('name')} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.name && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.name.message)}</p>}
</div>
<label className="flex items-center gap-2 text-sm font-medium text-secondary-700">
<input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} />
<input type="checkbox" {...register('is_default')} />
{t('currencies.isDefault')}
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => { setShowForm(false); setEditing(null); }} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">{t('common.cancel')}</button>
<button type="submit" className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">{t('common.save')}</button>
<button type="submit" disabled={isSubmitting} className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">{t('common.save')}</button>
</div>
</form>
)}
+26 -19
View File
@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
useGroups,
useCreateGroup,
@@ -81,9 +84,19 @@ export function SettingsGroupsPage() {
const updateGroupMutation = useUpdateGroup();
const deleteGroupMutation = useDeleteGroup();
// ── Zod Schema for create form ──
const groupCreateSchema = z.object({
name: z.string().min(1, 'required'),
description: z.string().optional().default(''),
});
type GroupCreateFormData = z.infer<typeof groupCreateSchema>;
const { register: registerGroup, handleSubmit: handleSubmitGroup, reset: resetGroup, formState: { errors: groupErrors } } = useForm<GroupCreateFormData>({
resolver: zodResolver(groupCreateSchema),
defaultValues: { name: '', description: '' },
});
const [createOpen, setCreateOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupDescription, setNewGroupDescription] = useState('');
const [newGroupPermissions, setNewGroupPermissions] = useState<Record<string, any>>({});
const [newGroupDenied, setNewGroupDenied] = useState<string[]>([]);
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
@@ -117,22 +130,17 @@ export function SettingsGroupsPage() {
return groups;
}, [allPermissions]);
const handleCreateGroup = async () => {
if (!newGroupName.trim()) {
toast.error(t('validation.required', 'Pflichtfeld'));
return;
}
const handleCreateGroup = async (data: GroupCreateFormData) => {
try {
await createGroupMutation.mutateAsync({
name: newGroupName.trim(),
description: newGroupDescription.trim() || null,
name: data.name.trim(),
description: data.description.trim() || null,
permissions: newGroupPermissions,
denied_permissions: newGroupDenied,
field_permissions: {},
});
toast.success(t('settings.groupCreated', 'Gruppe erstellt'));
setNewGroupName('');
setNewGroupDescription('');
resetGroup({ name: '', description: '' });
setNewGroupPermissions({});
setNewGroupDenied([]);
setCreateOpen(false);
@@ -243,7 +251,7 @@ export function SettingsGroupsPage() {
<h1 className="text-2xl font-bold text-secondary-900">
{t('settings.groups', 'Gruppen')}
</h1>
<Button onClick={() => setCreateOpen(true)} data-testid="create-group-btn">
<Button onClick={() => { resetGroup({ name: '', description: '' }); setCreateOpen(true); }} data-testid="create-group-btn">
{t('settings.createGroup', 'Gruppe erstellen')}
</Button>
</div>
@@ -317,18 +325,17 @@ export function SettingsGroupsPage() {
title={t('settings.createGroup', 'Gruppe erstellen')}
size="lg"
>
<div className="space-y-4" data-testid="create-group-form">
<form onSubmit={handleSubmitGroup(handleCreateGroup)} className="space-y-4" data-testid="create-group-form">
<Input
label={t('settings.groupName', 'Gruppenname')}
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
{...registerGroup('name')}
error={groupErrors.name?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('settings.groupName', 'Gruppenname')}
data-testid="new-group-name"
/>
<Input
label={t('settings.groupDescription', 'Beschreibung')}
value={newGroupDescription}
onChange={(e) => setNewGroupDescription(e.target.value)}
{...registerGroup('description')}
placeholder={t('settings.groupDescription', 'Beschreibung')}
data-testid="new-group-description"
/>
@@ -384,14 +391,14 @@ export function SettingsGroupsPage() {
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button>
<Button
onClick={handleCreateGroup}
type="submit"
isLoading={createGroupMutation.isPending}
data-testid="save-group-btn"
>
{t('common.save')}
</Button>
</div>
</div>
</form>
</Modal>
{/* Edit Group Modal */}
+23 -14
View File
@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions } from '@/api/hooks';
import type { PermissionItem, FieldDefinition } from '@/api/hooks';
import { Input } from '@/components/ui/Input';
@@ -69,8 +72,18 @@ export function SettingsRolesPage() {
const updateRoleMutation = useUpdateRole();
const deleteRoleMutation = useDeleteRole();
// ── Zod Schema for create form ──
const roleCreateSchema = z.object({
name: z.string().min(1, 'required'),
});
type RoleCreateFormData = z.infer<typeof roleCreateSchema>;
const { register: registerRole, handleSubmit: handleSubmitRole, reset: resetRole, formState: { errors: roleErrors } } = useForm<RoleCreateFormData>({
resolver: zodResolver(roleCreateSchema),
defaultValues: { name: '' },
});
const [createOpen, setCreateOpen] = useState(false);
const [newRoleName, setNewRoleName] = useState('');
const [newRolePermissions, setNewRolePermissions] = useState<Record<string, any>>({});
const [newRoleDenied, setNewRoleDenied] = useState<string[]>([]);
const [editingRole, setEditingRole] = useState<Role | null>(null);
@@ -117,20 +130,16 @@ export function SettingsRolesPage() {
return groups;
}, [fieldDefinitions]);
const handleCreateRole = async () => {
if (!newRoleName.trim()) {
toast.error(t('validation.required'));
return;
}
const handleCreateRole = async (data: RoleCreateFormData) => {
try {
await createRoleMutation.mutateAsync({
name: newRoleName.trim(),
name: data.name.trim(),
permissions: newRolePermissions,
denied_permissions: newRoleDenied,
field_permissions: {},
} as any);
toast.success(t('settings.roleCreated'));
setNewRoleName('');
resetRole({ name: '' });
setNewRolePermissions({});
setNewRoleDenied([]);
setCreateOpen(false);
@@ -238,7 +247,7 @@ export function SettingsRolesPage() {
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-roles-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.roles')}</h1>
<Button onClick={() => setCreateOpen(true)} data-testid="create-role-btn">
<Button onClick={() => { resetRole({ name: '' }); setCreateOpen(true); }} data-testid="create-role-btn">
{t('settings.createRole')}
</Button>
</div>
@@ -292,11 +301,11 @@ export function SettingsRolesPage() {
)}
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('settings.createRole')}>
<div className="space-y-4" data-testid="create-role-form">
<form onSubmit={handleSubmitRole(handleCreateRole)} className="space-y-4" data-testid="create-role-form">
<Input
label={t('settings.roleName')}
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
{...registerRole('name')}
error={roleErrors.name?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('settings.roleName')}
data-testid="new-role-name"
/>
@@ -350,14 +359,14 @@ export function SettingsRolesPage() {
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button>
<Button
onClick={handleCreateRole}
type="submit"
isLoading={createRoleMutation.isPending}
data-testid="save-role-btn"
>
{t('common.save')}
</Button>
</div>
</div>
</form>
</Modal>
<Modal open={!!editingRole} onClose={() => setEditingRole(null)} title={t('settings.roles') + ' — ' + (editingRole?.name || '')} size="xl">
+49 -13
View File
@@ -1,5 +1,8 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface Sequence {
@@ -10,6 +13,15 @@ interface Sequence {
padding: number;
}
// ── Zod Schema ──
const sequenceSchema = z.object({
name: z.string().min(1, 'required'),
prefix: z.string().optional().default(''),
padding: z.coerce.number().int().min(1, 'invalidNumber').max(10, 'invalidNumber'),
});
type SequenceFormData = z.infer<typeof sequenceSchema>;
export function SettingsSequencesPage() {
const { t } = useTranslation();
const [sequences, setSequences] = useState<Sequence[]>([]);
@@ -17,7 +29,16 @@ export function SettingsSequencesPage() {
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Sequence | null>(null);
const [formData, setFormData] = useState({ name: '', prefix: '', padding: 4 });
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<SequenceFormData>({
resolver: zodResolver(sequenceSchema),
defaultValues: { name: '', prefix: '', padding: 4 },
});
const fetchSequences = useCallback(async () => {
setLoading(true);
@@ -34,17 +55,16 @@ export function SettingsSequencesPage() {
React.useEffect(() => { fetchSequences(); }, [fetchSequences]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const onSubmit = async (data: SequenceFormData) => {
try {
if (editing) {
await apiPatch(`/sequences/${editing.id}`, formData);
await apiPatch(`/sequences/${editing.id}`, data);
} else {
await apiPost('/sequences', formData);
await apiPost('/sequences', data);
}
setShowForm(false);
setEditing(null);
setFormData({ name: '', prefix: '', padding: 4 });
reset({ name: '', prefix: '', padding: 4 });
await fetchSequences();
} catch (err: any) {
setError(err.message || t('common.error'));
@@ -63,10 +83,23 @@ export function SettingsSequencesPage() {
const handleEdit = (seq: Sequence) => {
setEditing(seq);
setFormData({ name: seq.name, prefix: seq.prefix, padding: seq.padding });
reset({ name: seq.name, prefix: seq.prefix, padding: seq.padding });
setShowForm(true);
};
const handleNew = () => {
setEditing(null);
reset({ name: '', prefix: '', padding: 4 });
setShowForm(true);
};
const errorMsg = (key: string | undefined) => {
if (!key) return undefined;
if (key === 'required') return t('validation.required');
if (key === 'invalidNumber') return t('validation.invalidNumber', 'Invalid number');
return key;
};
if (loading) return <div className="text-secondary-500">{t('common.loading')}</div>;
return (
@@ -74,7 +107,7 @@ export function SettingsSequencesPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-secondary-900">{t('sequences.title')}</h1>
<button
onClick={() => { setEditing(null); setFormData({ name: '', prefix: '', padding: 4 }); setShowForm(true); }}
onClick={handleNew}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
>
{t('sequences.add')}
@@ -84,24 +117,27 @@ export function SettingsSequencesPage() {
{error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{error}</div>}
{showForm && (
<form onSubmit={handleSave} className="p-4 border border-secondary-200 rounded-lg space-y-3">
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border border-secondary-200 rounded-lg space-y-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.name')}</label>
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('name')} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.name && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.name.message)}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.prefix')}</label>
<input type="text" value={formData.prefix} onChange={(e) => setFormData({ ...formData, prefix: e.target.value })} placeholder="RE-" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('prefix')} placeholder="RE-" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.prefix && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.prefix.message)}</p>}
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.padding')}</label>
<input type="number" min={1} max={10} value={formData.padding} onChange={(e) => setFormData({ ...formData, padding: parseInt(e.target.value) || 4 })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="number" min={1} max={10} {...register('padding')} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.padding && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.padding.message)}</p>}
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => { setShowForm(false); setEditing(null); }} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">{t('common.cancel')}</button>
<button type="submit" className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">{t('common.save')}</button>
<button type="submit" disabled={isSubmitting} className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">{t('common.save')}</button>
</div>
</form>
)}
+51 -13
View File
@@ -1,5 +1,8 @@
import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface TaxRate {
@@ -10,6 +13,16 @@ interface TaxRate {
country: string | null;
}
// ── Zod Schema ──
const taxSchema = z.object({
name: z.string().min(1, 'required'),
rate: z.coerce.number().min(0, 'invalidNumber').max(100, 'invalidNumber'),
is_default: z.boolean().default(false),
country: z.string().max(2, 'maxLength').optional().default(''),
});
type TaxFormData = z.infer<typeof taxSchema>;
export function SettingsTaxesPage() {
const { t } = useTranslation();
const [taxes, setTaxes] = useState<TaxRate[]>([]);
@@ -17,7 +30,16 @@ export function SettingsTaxesPage() {
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<TaxRate | null>(null);
const [formData, setFormData] = useState({ name: '', rate: 0, is_default: false, country: '' });
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<TaxFormData>({
resolver: zodResolver(taxSchema),
defaultValues: { name: '', rate: 0, is_default: false, country: '' },
});
const fetchTaxes = useCallback(async () => {
setLoading(true);
@@ -34,10 +56,9 @@ export function SettingsTaxesPage() {
React.useEffect(() => { fetchTaxes(); }, [fetchTaxes]);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
const onSubmit = async (data: TaxFormData) => {
try {
const payload = { ...formData, country: formData.country || null };
const payload = { ...data, country: data.country || null };
if (editing) {
await apiPatch(`/taxes/${editing.id}`, payload);
} else {
@@ -45,7 +66,7 @@ export function SettingsTaxesPage() {
}
setShowForm(false);
setEditing(null);
setFormData({ name: '', rate: 0, is_default: false, country: '' });
reset({ name: '', rate: 0, is_default: false, country: '' });
await fetchTaxes();
} catch (err: any) {
setError(err.message || t('common.error'));
@@ -64,10 +85,24 @@ export function SettingsTaxesPage() {
const handleEdit = (tax: TaxRate) => {
setEditing(tax);
setFormData({ name: tax.name, rate: tax.rate, is_default: tax.is_default, country: tax.country || '' });
reset({ name: tax.name, rate: tax.rate, is_default: tax.is_default, country: tax.country || '' });
setShowForm(true);
};
const handleNew = () => {
setEditing(null);
reset({ name: '', rate: 0, is_default: false, country: '' });
setShowForm(true);
};
const errorMsg = (key: string | undefined) => {
if (!key) return undefined;
if (key === 'required') return t('validation.required');
if (key === 'invalidNumber') return t('validation.invalidNumber', 'Invalid number');
if (key === 'maxLength') return t('validation.maxLength', { count: 2 });
return key;
};
if (loading) return <div className="text-secondary-500">{t('common.loading')}</div>;
return (
@@ -75,7 +110,7 @@ export function SettingsTaxesPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-secondary-900">{t('taxes.title')}</h1>
<button
onClick={() => { setEditing(null); setFormData({ name: '', rate: 0, is_default: false, country: '' }); setShowForm(true); }}
onClick={handleNew}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
>
{t('taxes.add')}
@@ -85,28 +120,31 @@ export function SettingsTaxesPage() {
{error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{error}</div>}
{showForm && (
<form onSubmit={handleSave} className="p-4 border border-secondary-200 rounded-lg space-y-3">
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border border-secondary-200 rounded-lg space-y-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.name')}</label>
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('name')} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.name && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.name.message)}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.rate')} (%)</label>
<input type="number" step="0.01" value={formData.rate} onChange={(e) => setFormData({ ...formData, rate: parseFloat(e.target.value) || 0 })} required className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="number" step="0.01" {...register('rate')} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.rate && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.rate.message)}</p>}
</div>
<div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.country')}</label>
<input type="text" value={formData.country} onChange={(e) => setFormData({ ...formData, country: e.target.value })} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
<input type="text" {...register('country')} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" />
{errors.country && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.country.message)}</p>}
</div>
</div>
<label className="flex items-center gap-2 text-sm font-medium text-secondary-700">
<input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} />
<input type="checkbox" {...register('is_default')} />
{t('taxes.isDefault')}
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => { setShowForm(false); setEditing(null); }} className="px-3 py-1.5 text-sm font-medium text-secondary-700 bg-secondary-100 rounded-lg hover:bg-secondary-200">{t('common.cancel')}</button>
<button type="submit" className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">{t('common.save')}</button>
<button type="submit" disabled={isSubmitting} className="px-3 py-1.5 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700">{t('common.save')}</button>
</div>
</form>
)}
+51 -36
View File
@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser, useRoles } from '@/api/hooks';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
@@ -21,6 +24,16 @@ const LEGACY_ROLES = [
{ value: 'viewer', label: 'Viewer' },
];
// ── Zod Schema ──
const inviteSchema = z.object({
name: z.string().min(1, 'required'),
email: z.string().min(1, 'required').email('invalidEmail'),
password: z.string().min(8, 'passwordTooShort'),
role_id: z.string().optional().default(''),
});
type InviteFormData = z.infer<typeof inviteSchema>;
export function SettingsUsersPage() {
const { t } = useTranslation();
const toast = useToast();
@@ -31,13 +44,19 @@ export function SettingsUsersPage() {
const deleteUserMutation = useDeleteUser();
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteName, setInviteName] = useState('');
const [invitePassword, setInvitePassword] = useState('');
const [inviteRoleId, setInviteRoleId] = useState('');
const [confirmDeactivate, setConfirmDeactivate] = useState<any>(null);
const [confirmDelete, setConfirmDelete] = useState<any>(null);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: { name: '', email: '', password: '', role_id: '' },
});
const users = data?.items ?? [];
const customRoles = rolesData?.items ?? [];
@@ -69,34 +88,23 @@ export function SettingsUsersPage() {
return 'viewer';
};
const handleInvite = async () => {
if (!inviteEmail.trim() || !inviteName.trim() || !invitePassword.trim()) {
toast.error(t('validation.required'));
return;
}
if (invitePassword.length < 8) {
toast.error(t('auth.passwordTooShort'));
return;
}
const onSubmit = async (data: InviteFormData) => {
try {
const payload: any = {
email: inviteEmail.trim(),
name: inviteName.trim(),
password: invitePassword,
email: data.email.trim(),
name: data.name.trim(),
password: data.password,
is_active: true,
};
if (inviteRoleId.startsWith('role_id:')) {
payload.role_id = inviteRoleId.substring('role_id:'.length);
if (data.role_id.startsWith('role_id:')) {
payload.role_id = data.role_id.substring('role_id:'.length);
payload.role = 'viewer';
} else {
payload.role = inviteRoleId || 'viewer';
payload.role = data.role_id || 'viewer';
}
await createUserMutation.mutateAsync(payload);
toast.success(t('settings.userInvited'));
setInviteEmail('');
setInviteName('');
setInvitePassword('');
setInviteRoleId('');
reset({ name: '', email: '', password: '', role_id: '' });
setInviteOpen(false);
} catch (err: any) {
toast.error(err.message || t('common.error'));
@@ -147,6 +155,14 @@ export function SettingsUsersPage() {
}
};
const errorMsg = (key: string | undefined) => {
if (!key) return undefined;
if (key === 'required') return t('validation.required');
if (key === 'invalidEmail') return t('validation.email');
if (key === 'passwordTooShort') return t('auth.passwordTooShort');
return key;
};
if (isLoading) {
return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
@@ -164,7 +180,7 @@ export function SettingsUsersPage() {
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.users')}</h1>
<Button onClick={() => setInviteOpen(true)} data-testid="invite-user-btn">
<Button onClick={() => { reset({ name: '', email: '', password: '', role_id: '' }); setInviteOpen(true); }} data-testid="invite-user-btn">
{t('settings.inviteUser')}
</Button>
</div>
@@ -228,48 +244,47 @@ export function SettingsUsersPage() {
)}
<Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title={t('settings.inviteUser')}>
<div className="space-y-4" data-testid="invite-user-form">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" data-testid="invite-user-form">
<Input
label={t('settings.name')}
type="text"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
{...register('name')}
error={errorMsg(errors.name?.message)}
placeholder={t('settings.name')}
data-testid="invite-name"
/>
<Input
label={t('settings.inviteEmail')}
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
{...register('email')}
error={errorMsg(errors.email?.message)}
placeholder="neu.mitarbeiter@firma.de"
data-testid="invite-email"
/>
<Input
label={t('auth.password')}
type="password"
value={invitePassword}
onChange={(e) => setInvitePassword(e.target.value)}
{...register('password')}
error={errorMsg(errors.password?.message)}
placeholder="********"
data-testid="invite-password"
/>
<Select
label={t('settings.inviteRole')}
options={roleOptions}
value={inviteRoleId}
onChange={(e) => setInviteRoleId(e.target.value)}
{...register('role_id')}
/>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setInviteOpen(false)}>{t('common.cancel')}</Button>
<Button
onClick={handleInvite}
isLoading={createUserMutation.isPending}
type="submit"
isLoading={isSubmitting}
data-testid="send-invite-btn"
>
{t('settings.inviteUser')}
</Button>
</div>
</div>
</form>
</Modal>
<ConfirmDialog