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