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
@@ -0,0 +1,55 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('@/api/client', () => ({
apiGet: vi.fn().mockResolvedValue({ items: [], total: 0 }),
apiPost: vi.fn().mockResolvedValue({ id: '1', code: 'EUR', name: 'Euro', symbol: '€', is_default: true }),
apiPatch: vi.fn().mockResolvedValue({}),
apiDelete: vi.fn().mockResolvedValue({}),
}));
import { SettingsCurrenciesPage } from '@/pages/SettingsCurrencies';
import { apiPost } from '@/api/client';
beforeEach(() => {
vi.clearAllMocks();
});
describe('SettingsCurrencies validation (RHF + Zod)', () => {
it('shows validation error when code is empty', async () => {
render(<SettingsCurrenciesPage />);
await waitFor(() => {
expect(screen.getByTestId('settings-currencies')).toBeInTheDocument();
});
// Click add button to show form (German locale default in test env)
const addBtn = screen.getByText(/währung hinzufügen|add currency/i);
fireEvent.click(addBtn);
// Submit empty form
const saveBtn = screen.getByRole('button', { name: /speichern|^save$/i });
fireEvent.click(saveBtn);
await waitFor(() => {
const errors = screen.getAllByText(/erforderlich|required/i);
expect(errors.length).toBeGreaterThan(0);
});
});
it('submits successfully with valid data', async () => {
render(<SettingsCurrenciesPage />);
await waitFor(() => {
expect(screen.getByTestId('settings-currencies')).toBeInTheDocument();
});
const addBtn = screen.getByText(/währung hinzufügen|add currency/i);
fireEvent.click(addBtn);
// Fill form — code, symbol, name
const inputs = screen.getAllByRole('textbox');
fireEvent.change(inputs[0], { target: { value: 'EUR' } }); // code
fireEvent.change(inputs[1], { target: { value: '€' } }); // symbol
fireEvent.change(inputs[2], { target: { value: 'Euro' } }); // name
const saveBtn = screen.getByRole('button', { name: /speichern|^save$/i });
fireEvent.click(saveBtn);
await waitFor(() => {
expect(apiPost).toHaveBeenCalledWith('/currencies', expect.objectContaining({ code: 'EUR', name: 'Euro', symbol: '€' }));
});
});
});
+51 -14
View File
@@ -1,5 +1,8 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; 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'; import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface Currency { interface Currency {
@@ -10,6 +13,16 @@ interface Currency {
is_default: boolean; 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() { export function SettingsCurrenciesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [currencies, setCurrencies] = useState<Currency[]>([]); const [currencies, setCurrencies] = useState<Currency[]>([]);
@@ -17,7 +30,16 @@ export function SettingsCurrenciesPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Currency | null>(null); 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 () => { const fetchCurrencies = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -34,17 +56,16 @@ export function SettingsCurrenciesPage() {
React.useEffect(() => { fetchCurrencies(); }, [fetchCurrencies]); React.useEffect(() => { fetchCurrencies(); }, [fetchCurrencies]);
const handleSave = async (e: React.FormEvent) => { const onSubmit = async (data: CurrencyFormData) => {
e.preventDefault();
try { try {
if (editing) { if (editing) {
await apiPatch(`/currencies/${editing.id}`, formData); await apiPatch(`/currencies/${editing.id}`, data);
} else { } else {
await apiPost('/currencies', formData); await apiPost('/currencies', data);
} }
setShowForm(false); setShowForm(false);
setEditing(null); setEditing(null);
setFormData({ code: '', name: '', symbol: '', is_default: false }); reset({ code: '', name: '', symbol: '', is_default: false });
await fetchCurrencies(); await fetchCurrencies();
} catch (err: any) { } catch (err: any) {
setError(err.message || t('common.error')); setError(err.message || t('common.error'));
@@ -63,10 +84,23 @@ export function SettingsCurrenciesPage() {
const handleEdit = (c: Currency) => { const handleEdit = (c: Currency) => {
setEditing(c); 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); 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>; if (loading) return <div className="text-secondary-500">{t('common.loading')}</div>;
return ( return (
@@ -74,7 +108,7 @@ export function SettingsCurrenciesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-secondary-900">{t('currencies.title')}</h1> <h1 className="text-2xl font-bold text-secondary-900">{t('currencies.title')}</h1>
<button <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" className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
> >
{t('currencies.add')} {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>} {error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{error}</div>}
{showForm && ( {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 className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('currencies.code')}</label> <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>
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('currencies.symbol')}</label> <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> </div>
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('currencies.name')}</label> <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> </div>
<label className="flex items-center gap-2 text-sm font-medium text-secondary-700"> <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')} {t('currencies.isDefault')}
</label> </label>
<div className="flex justify-end gap-2"> <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="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> </div>
</form> </form>
)} )}
+26 -19
View File
@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { import {
useGroups, useGroups,
useCreateGroup, useCreateGroup,
@@ -81,9 +84,19 @@ export function SettingsGroupsPage() {
const updateGroupMutation = useUpdateGroup(); const updateGroupMutation = useUpdateGroup();
const deleteGroupMutation = useDeleteGroup(); 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 [createOpen, setCreateOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [newGroupDescription, setNewGroupDescription] = useState('');
const [newGroupPermissions, setNewGroupPermissions] = useState<Record<string, any>>({}); const [newGroupPermissions, setNewGroupPermissions] = useState<Record<string, any>>({});
const [newGroupDenied, setNewGroupDenied] = useState<string[]>([]); const [newGroupDenied, setNewGroupDenied] = useState<string[]>([]);
const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null); const [editingGroup, setEditingGroup] = useState<EditingGroup | null>(null);
@@ -117,22 +130,17 @@ export function SettingsGroupsPage() {
return groups; return groups;
}, [allPermissions]); }, [allPermissions]);
const handleCreateGroup = async () => { const handleCreateGroup = async (data: GroupCreateFormData) => {
if (!newGroupName.trim()) {
toast.error(t('validation.required', 'Pflichtfeld'));
return;
}
try { try {
await createGroupMutation.mutateAsync({ await createGroupMutation.mutateAsync({
name: newGroupName.trim(), name: data.name.trim(),
description: newGroupDescription.trim() || null, description: data.description.trim() || null,
permissions: newGroupPermissions, permissions: newGroupPermissions,
denied_permissions: newGroupDenied, denied_permissions: newGroupDenied,
field_permissions: {}, field_permissions: {},
}); });
toast.success(t('settings.groupCreated', 'Gruppe erstellt')); toast.success(t('settings.groupCreated', 'Gruppe erstellt'));
setNewGroupName(''); resetGroup({ name: '', description: '' });
setNewGroupDescription('');
setNewGroupPermissions({}); setNewGroupPermissions({});
setNewGroupDenied([]); setNewGroupDenied([]);
setCreateOpen(false); setCreateOpen(false);
@@ -243,7 +251,7 @@ export function SettingsGroupsPage() {
<h1 className="text-2xl font-bold text-secondary-900"> <h1 className="text-2xl font-bold text-secondary-900">
{t('settings.groups', 'Gruppen')} {t('settings.groups', 'Gruppen')}
</h1> </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')} {t('settings.createGroup', 'Gruppe erstellen')}
</Button> </Button>
</div> </div>
@@ -317,18 +325,17 @@ export function SettingsGroupsPage() {
title={t('settings.createGroup', 'Gruppe erstellen')} title={t('settings.createGroup', 'Gruppe erstellen')}
size="lg" 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 <Input
label={t('settings.groupName', 'Gruppenname')} label={t('settings.groupName', 'Gruppenname')}
value={newGroupName} {...registerGroup('name')}
onChange={(e) => setNewGroupName(e.target.value)} error={groupErrors.name?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('settings.groupName', 'Gruppenname')} placeholder={t('settings.groupName', 'Gruppenname')}
data-testid="new-group-name" data-testid="new-group-name"
/> />
<Input <Input
label={t('settings.groupDescription', 'Beschreibung')} label={t('settings.groupDescription', 'Beschreibung')}
value={newGroupDescription} {...registerGroup('description')}
onChange={(e) => setNewGroupDescription(e.target.value)}
placeholder={t('settings.groupDescription', 'Beschreibung')} placeholder={t('settings.groupDescription', 'Beschreibung')}
data-testid="new-group-description" data-testid="new-group-description"
/> />
@@ -384,14 +391,14 @@ export function SettingsGroupsPage() {
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button> <Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button>
<Button <Button
onClick={handleCreateGroup} type="submit"
isLoading={createGroupMutation.isPending} isLoading={createGroupMutation.isPending}
data-testid="save-group-btn" data-testid="save-group-btn"
> >
{t('common.save')} {t('common.save')}
</Button> </Button>
</div> </div>
</div> </form>
</Modal> </Modal>
{/* Edit Group Modal */} {/* Edit Group Modal */}
+23 -14
View File
@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useRoles, useCreateRole, useUpdateRole, useDeleteRole, usePermissions } from '@/api/hooks';
import type { PermissionItem, FieldDefinition } from '@/api/hooks'; import type { PermissionItem, FieldDefinition } from '@/api/hooks';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
@@ -69,8 +72,18 @@ export function SettingsRolesPage() {
const updateRoleMutation = useUpdateRole(); const updateRoleMutation = useUpdateRole();
const deleteRoleMutation = useDeleteRole(); 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 [createOpen, setCreateOpen] = useState(false);
const [newRoleName, setNewRoleName] = useState('');
const [newRolePermissions, setNewRolePermissions] = useState<Record<string, any>>({}); const [newRolePermissions, setNewRolePermissions] = useState<Record<string, any>>({});
const [newRoleDenied, setNewRoleDenied] = useState<string[]>([]); const [newRoleDenied, setNewRoleDenied] = useState<string[]>([]);
const [editingRole, setEditingRole] = useState<Role | null>(null); const [editingRole, setEditingRole] = useState<Role | null>(null);
@@ -117,20 +130,16 @@ export function SettingsRolesPage() {
return groups; return groups;
}, [fieldDefinitions]); }, [fieldDefinitions]);
const handleCreateRole = async () => { const handleCreateRole = async (data: RoleCreateFormData) => {
if (!newRoleName.trim()) {
toast.error(t('validation.required'));
return;
}
try { try {
await createRoleMutation.mutateAsync({ await createRoleMutation.mutateAsync({
name: newRoleName.trim(), name: data.name.trim(),
permissions: newRolePermissions, permissions: newRolePermissions,
denied_permissions: newRoleDenied, denied_permissions: newRoleDenied,
field_permissions: {}, field_permissions: {},
} as any); } as any);
toast.success(t('settings.roleCreated')); toast.success(t('settings.roleCreated'));
setNewRoleName(''); resetRole({ name: '' });
setNewRolePermissions({}); setNewRolePermissions({});
setNewRoleDenied([]); setNewRoleDenied([]);
setCreateOpen(false); 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="p-6 max-w-4xl mx-auto" data-testid="settings-roles-page">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.roles')}</h1> <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')} {t('settings.createRole')}
</Button> </Button>
</div> </div>
@@ -292,11 +301,11 @@ export function SettingsRolesPage() {
)} )}
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('settings.createRole')}> <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 <Input
label={t('settings.roleName')} label={t('settings.roleName')}
value={newRoleName} {...registerRole('name')}
onChange={(e) => setNewRoleName(e.target.value)} error={roleErrors.name?.message === 'required' ? t('validation.required') : undefined}
placeholder={t('settings.roleName')} placeholder={t('settings.roleName')}
data-testid="new-role-name" data-testid="new-role-name"
/> />
@@ -350,14 +359,14 @@ export function SettingsRolesPage() {
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button> <Button variant="secondary" onClick={() => setCreateOpen(false)}>{t('common.cancel')}</Button>
<Button <Button
onClick={handleCreateRole} type="submit"
isLoading={createRoleMutation.isPending} isLoading={createRoleMutation.isPending}
data-testid="save-role-btn" data-testid="save-role-btn"
> >
{t('common.save')} {t('common.save')}
</Button> </Button>
</div> </div>
</div> </form>
</Modal> </Modal>
<Modal open={!!editingRole} onClose={() => setEditingRole(null)} title={t('settings.roles') + ' — ' + (editingRole?.name || '')} size="xl"> <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 React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; 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'; import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface Sequence { interface Sequence {
@@ -10,6 +13,15 @@ interface Sequence {
padding: number; 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() { export function SettingsSequencesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [sequences, setSequences] = useState<Sequence[]>([]); const [sequences, setSequences] = useState<Sequence[]>([]);
@@ -17,7 +29,16 @@ export function SettingsSequencesPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Sequence | null>(null); 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 () => { const fetchSequences = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -34,17 +55,16 @@ export function SettingsSequencesPage() {
React.useEffect(() => { fetchSequences(); }, [fetchSequences]); React.useEffect(() => { fetchSequences(); }, [fetchSequences]);
const handleSave = async (e: React.FormEvent) => { const onSubmit = async (data: SequenceFormData) => {
e.preventDefault();
try { try {
if (editing) { if (editing) {
await apiPatch(`/sequences/${editing.id}`, formData); await apiPatch(`/sequences/${editing.id}`, data);
} else { } else {
await apiPost('/sequences', formData); await apiPost('/sequences', data);
} }
setShowForm(false); setShowForm(false);
setEditing(null); setEditing(null);
setFormData({ name: '', prefix: '', padding: 4 }); reset({ name: '', prefix: '', padding: 4 });
await fetchSequences(); await fetchSequences();
} catch (err: any) { } catch (err: any) {
setError(err.message || t('common.error')); setError(err.message || t('common.error'));
@@ -63,10 +83,23 @@ export function SettingsSequencesPage() {
const handleEdit = (seq: Sequence) => { const handleEdit = (seq: Sequence) => {
setEditing(seq); setEditing(seq);
setFormData({ name: seq.name, prefix: seq.prefix, padding: seq.padding }); reset({ name: seq.name, prefix: seq.prefix, padding: seq.padding });
setShowForm(true); 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>; if (loading) return <div className="text-secondary-500">{t('common.loading')}</div>;
return ( return (
@@ -74,7 +107,7 @@ export function SettingsSequencesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-secondary-900">{t('sequences.title')}</h1> <h1 className="text-2xl font-bold text-secondary-900">{t('sequences.title')}</h1>
<button <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" className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
> >
{t('sequences.add')} {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>} {error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{error}</div>}
{showForm && ( {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> <div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.name')}</label> <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>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.prefix')}</label> <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>
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('sequences.padding')}</label> <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> </div>
<div className="flex justify-end gap-2"> <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="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> </div>
</form> </form>
)} )}
+51 -13
View File
@@ -1,5 +1,8 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; 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'; import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client';
interface TaxRate { interface TaxRate {
@@ -10,6 +13,16 @@ interface TaxRate {
country: string | null; 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() { export function SettingsTaxesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [taxes, setTaxes] = useState<TaxRate[]>([]); const [taxes, setTaxes] = useState<TaxRate[]>([]);
@@ -17,7 +30,16 @@ export function SettingsTaxesPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<TaxRate | null>(null); 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 () => { const fetchTaxes = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -34,10 +56,9 @@ export function SettingsTaxesPage() {
React.useEffect(() => { fetchTaxes(); }, [fetchTaxes]); React.useEffect(() => { fetchTaxes(); }, [fetchTaxes]);
const handleSave = async (e: React.FormEvent) => { const onSubmit = async (data: TaxFormData) => {
e.preventDefault();
try { try {
const payload = { ...formData, country: formData.country || null }; const payload = { ...data, country: data.country || null };
if (editing) { if (editing) {
await apiPatch(`/taxes/${editing.id}`, payload); await apiPatch(`/taxes/${editing.id}`, payload);
} else { } else {
@@ -45,7 +66,7 @@ export function SettingsTaxesPage() {
} }
setShowForm(false); setShowForm(false);
setEditing(null); setEditing(null);
setFormData({ name: '', rate: 0, is_default: false, country: '' }); reset({ name: '', rate: 0, is_default: false, country: '' });
await fetchTaxes(); await fetchTaxes();
} catch (err: any) { } catch (err: any) {
setError(err.message || t('common.error')); setError(err.message || t('common.error'));
@@ -64,10 +85,24 @@ export function SettingsTaxesPage() {
const handleEdit = (tax: TaxRate) => { const handleEdit = (tax: TaxRate) => {
setEditing(tax); 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); 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>; if (loading) return <div className="text-secondary-500">{t('common.loading')}</div>;
return ( return (
@@ -75,7 +110,7 @@ export function SettingsTaxesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-secondary-900">{t('taxes.title')}</h1> <h1 className="text-2xl font-bold text-secondary-900">{t('taxes.title')}</h1>
<button <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" className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700"
> >
{t('taxes.add')} {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>} {error && <div className="p-3 text-sm text-red-700 bg-red-50 rounded-lg">{error}</div>}
{showForm && ( {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> <div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.name')}</label> <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>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.rate')} (%)</label> <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>
<div> <div>
<label className="block text-sm font-medium text-secondary-700">{t('taxes.country')}</label> <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>
</div> </div>
<label className="flex items-center gap-2 text-sm font-medium text-secondary-700"> <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')} {t('taxes.isDefault')}
</label> </label>
<div className="flex justify-end gap-2"> <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="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> </div>
</form> </form>
)} )}
+51 -36
View File
@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; 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 { useUsers, useCreateUser, useUpdateUser, useDeleteUser, useRoles } from '@/api/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
@@ -21,6 +24,16 @@ const LEGACY_ROLES = [
{ value: 'viewer', label: 'Viewer' }, { 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() { export function SettingsUsersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
@@ -31,13 +44,19 @@ export function SettingsUsersPage() {
const deleteUserMutation = useDeleteUser(); const deleteUserMutation = useDeleteUser();
const [inviteOpen, setInviteOpen] = useState(false); 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 [confirmDeactivate, setConfirmDeactivate] = useState<any>(null);
const [confirmDelete, setConfirmDelete] = 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 users = data?.items ?? [];
const customRoles = rolesData?.items ?? []; const customRoles = rolesData?.items ?? [];
@@ -69,34 +88,23 @@ export function SettingsUsersPage() {
return 'viewer'; return 'viewer';
}; };
const handleInvite = async () => { const onSubmit = async (data: InviteFormData) => {
if (!inviteEmail.trim() || !inviteName.trim() || !invitePassword.trim()) {
toast.error(t('validation.required'));
return;
}
if (invitePassword.length < 8) {
toast.error(t('auth.passwordTooShort'));
return;
}
try { try {
const payload: any = { const payload: any = {
email: inviteEmail.trim(), email: data.email.trim(),
name: inviteName.trim(), name: data.name.trim(),
password: invitePassword, password: data.password,
is_active: true, is_active: true,
}; };
if (inviteRoleId.startsWith('role_id:')) { if (data.role_id.startsWith('role_id:')) {
payload.role_id = inviteRoleId.substring('role_id:'.length); payload.role_id = data.role_id.substring('role_id:'.length);
payload.role = 'viewer'; payload.role = 'viewer';
} else { } else {
payload.role = inviteRoleId || 'viewer'; payload.role = data.role_id || 'viewer';
} }
await createUserMutation.mutateAsync(payload); await createUserMutation.mutateAsync(payload);
toast.success(t('settings.userInvited')); toast.success(t('settings.userInvited'));
setInviteEmail(''); reset({ name: '', email: '', password: '', role_id: '' });
setInviteName('');
setInvitePassword('');
setInviteRoleId('');
setInviteOpen(false); setInviteOpen(false);
} catch (err: any) { } catch (err: any) {
toast.error(err.message || t('common.error')); 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) { if (isLoading) {
return ( return (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page"> <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="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.users')}</h1> <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')} {t('settings.inviteUser')}
</Button> </Button>
</div> </div>
@@ -228,48 +244,47 @@ export function SettingsUsersPage() {
)} )}
<Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title={t('settings.inviteUser')}> <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 <Input
label={t('settings.name')} label={t('settings.name')}
type="text" type="text"
value={inviteName} {...register('name')}
onChange={(e) => setInviteName(e.target.value)} error={errorMsg(errors.name?.message)}
placeholder={t('settings.name')} placeholder={t('settings.name')}
data-testid="invite-name" data-testid="invite-name"
/> />
<Input <Input
label={t('settings.inviteEmail')} label={t('settings.inviteEmail')}
type="email" type="email"
value={inviteEmail} {...register('email')}
onChange={(e) => setInviteEmail(e.target.value)} error={errorMsg(errors.email?.message)}
placeholder="neu.mitarbeiter@firma.de" placeholder="neu.mitarbeiter@firma.de"
data-testid="invite-email" data-testid="invite-email"
/> />
<Input <Input
label={t('auth.password')} label={t('auth.password')}
type="password" type="password"
value={invitePassword} {...register('password')}
onChange={(e) => setInvitePassword(e.target.value)} error={errorMsg(errors.password?.message)}
placeholder="********" placeholder="********"
data-testid="invite-password" data-testid="invite-password"
/> />
<Select <Select
label={t('settings.inviteRole')} label={t('settings.inviteRole')}
options={roleOptions} options={roleOptions}
value={inviteRoleId} {...register('role_id')}
onChange={(e) => setInviteRoleId(e.target.value)}
/> />
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setInviteOpen(false)}>{t('common.cancel')}</Button> <Button variant="secondary" onClick={() => setInviteOpen(false)}>{t('common.cancel')}</Button>
<Button <Button
onClick={handleInvite} type="submit"
isLoading={createUserMutation.isPending} isLoading={isSubmitting}
data-testid="send-invite-btn" data-testid="send-invite-btn"
> >
{t('settings.inviteUser')} {t('settings.inviteUser')}
</Button> </Button>
</div> </div>
</div> </form>
</Modal> </Modal>
<ConfirmDialog <ConfirmDialog