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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user