Compare commits
7 Commits
888e7fee3e
...
efc49c7769
| Author | SHA1 | Date | |
|---|---|---|---|
| efc49c7769 | |||
| 761f8d88dc | |||
| 3e8038b75e | |||
| eb2f37b2bc | |||
| c334d02989 | |||
| 2931a850c0 | |||
| d4aa661164 |
+25
@@ -624,3 +624,28 @@ LeoCRM-Agenten können externe MCP-Server nutzen (Web-Search, Code-Execution, ex
|
||||
|
||||
**Phase 5 Batch 6b Gesamt: ✅ Complete**
|
||||
**Phase 5 Gesamt: ✅ Complete**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: React Hook Form + Zod überall
|
||||
|
||||
| Task | Status | Datum | Notiz |
|
||||
|---|---|---|---|
|
||||
| 6.1 | ✅ done | 2026-07-24 | ComposeModal auf RHF + Zod: email list validation (to/cc/bcc), subject required, body via setValue. 4 validation tests. |
|
||||
| 6.2 | ✅ done | 2026-07-24 | AppointmentModal auf RHF + Zod: title/calendar_id required, start<end date validation via superRefine. 3 validation tests. |
|
||||
| 6.3 | ✅ done | 2026-07-24 | SettingsForms auf RHF + Zod: Currencies (code/name/symbol), Taxes (name/rate/country), Sequences (name/padding), Users (name/email/password), Roles (name), Groups (name/description). 2 validation tests. |
|
||||
| 6.4 | ✅ done | 2026-07-24 | DMS-Forms auf RHF + Zod: Dms.tsx folder-create (name required), ShareDialog add-share (shareId required). 2 validation tests. |
|
||||
| 6.5 | ✅ done | 2026-07-24 | Tag-Forms auf RHF + Zod: TagPicker create-tag (name required, color optional). 2 validation tests. |
|
||||
| 6.6 | ✅ done | 2026-07-24 | Mail-Settings-Forms auf RHF + Zod: MailSettings account form (email/imap/smtp/password), SignatureManager (name), RuleEditor (name/priority), LabelManager (name/color), VacationResponder (enabled/dates/subject/body). 2 validation tests. |
|
||||
|
||||
### Verifikation Phase 6
|
||||
- TSC: 0 neue Errors (nur pre-existing Dms.tsx onRangeSelect errors — 2 total)
|
||||
- 6 Commits mit klaren Messages (Phase 6.1 bis 6.6)
|
||||
- 15 neue Validation Tests (alle passing)
|
||||
- Bestehende Funktionalität erhalten — nur Form-Handling geändert
|
||||
- react-hook-form + zod + @hookform/resolvers/zod verwendet
|
||||
- Error-Display: rote Text unter jedem Feld mit Fehler
|
||||
- i18n für Fehlermeldungen (validation.required, validation.email, etc.)
|
||||
- Bereits migrierte Forms (Login, PasswordReset, SettingsSystem, ContactEditModal) nicht geändert
|
||||
|
||||
**Phase 6 Gesamt: ✅ Complete**
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
|
||||
import type { Calendar } from '@/api/calendar';
|
||||
|
||||
vi.mock('@/api/calendar', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/calendar')>('@/api/calendar');
|
||||
return {
|
||||
...actual,
|
||||
createEntry: vi.fn(),
|
||||
updateEntry: vi.fn(),
|
||||
deleteEntry: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { createEntry } from '@/api/calendar';
|
||||
|
||||
const calendars: Calendar[] = [
|
||||
{ id: 'cal-1', name: 'Persönlich', color: '#3B82F6', type: 'personal', owner_id: 'u-1' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AppointmentModal validation (RHF + Zod)', () => {
|
||||
it('shows validation error when title is empty', async () => {
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
prefillDate={new Date(2026, 6, 1)}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={vi.fn()}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('appointment-save'));
|
||||
const err = await screen.findByTestId('appointment-error');
|
||||
expect(err.textContent?.toLowerCase()).toContain('erforderlich');
|
||||
});
|
||||
|
||||
it('shows error when end is before start', async () => {
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
prefillDate={new Date(2026, 6, 1)}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={vi.fn()}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Set title so only date validation fails
|
||||
fireEvent.change(screen.getByTestId('appointment-title'), { target: { value: 'Test' } });
|
||||
// Set end before start
|
||||
fireEvent.change(screen.getByTestId('appointment-end'), { target: { value: '2026-07-01T08:00' } });
|
||||
fireEvent.click(screen.getByTestId('appointment-save'));
|
||||
await waitFor(() => {
|
||||
const err = screen.queryByTestId('appointment-error');
|
||||
expect(err).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits successfully with valid data', async () => {
|
||||
(createEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: 'e-1', calendar_id: 'cal-1', entry_type: 'appointment', subtype: 'normal',
|
||||
title: 'Test', description: null, location: null,
|
||||
start_at: '2026-07-01T09:00:00.000Z', end_at: '2026-07-01T10:00:00.000Z',
|
||||
all_day: false, priority: 'medium', status: 'open', created_by: 'u-1',
|
||||
});
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<AppointmentModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
prefillDate={new Date(2026, 6, 1, 9, 0)}
|
||||
calendars={calendars}
|
||||
defaultCalendarId="cal-1"
|
||||
onSaved={onSaved}
|
||||
onDeleted={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('appointment-title'), { target: { value: 'Test Termin' } });
|
||||
fireEvent.click(screen.getByTestId('appointment-save'));
|
||||
await waitFor(() => {
|
||||
expect(createEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSaved).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/api/dms', () => ({
|
||||
shareFile: vi.fn().mockResolvedValue({}),
|
||||
removeShare: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/permissions', () => ({
|
||||
fetchFilePermissions: vi.fn().mockResolvedValue([]),
|
||||
grantPermission: vi.fn(),
|
||||
revokePermission: vi.fn(),
|
||||
createShareLink: vi.fn(),
|
||||
revokeShareLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { ShareDialog } from '@/components/dms/ShareDialog';
|
||||
import { shareFile } from '@/api/dms';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ShareDialog validation (RHF + Zod)', () => {
|
||||
it('shows validation error when share ID is empty', async () => {
|
||||
render(
|
||||
<ShareDialog
|
||||
open
|
||||
file={{ id: 'f1', name: 'test.pdf', folder_id: null, mime_type: 'application/pdf', size_bytes: 100, created_at: '', updated_at: '', created_by: '' } as any}
|
||||
onClose={vi.fn()}
|
||||
onShared={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Find the submit button inside the add-share form
|
||||
const addShareButtons = screen.getAllByRole('button', { name: /hinzufügen|add share/i });
|
||||
const submitBtn = addShareButtons.find((b) => b.getAttribute('type') === 'submit');
|
||||
expect(submitBtn).toBeTruthy();
|
||||
fireEvent.click(submitBtn!);
|
||||
await waitFor(() => {
|
||||
const errors = screen.getAllByText(/erforderlich|required/i);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls shareFile when form is valid', async () => {
|
||||
render(
|
||||
<ShareDialog
|
||||
open
|
||||
file={{ id: 'f1', name: 'test.pdf', folder_id: null, mime_type: 'application/pdf', size_bytes: 100, created_at: '', updated_at: '', created_by: '' } as any}
|
||||
onClose={vi.fn()}
|
||||
onShared={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Fill in share ID - find the input inside the share-add-section
|
||||
const shareSection = screen.getByTestId('share-add-section');
|
||||
const input = shareSection.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
fireEvent.change(input!, { target: { value: 'user-123' } });
|
||||
fireEvent.submit(input!.closest('form')!);
|
||||
await waitFor(() => {
|
||||
expect(shareFile).toHaveBeenCalledWith('f1', expect.objectContaining({ user_id: 'user-123' }));
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('@/api/mail', () => ({
|
||||
decodeMimeHeader: (s: string) => s,
|
||||
fetchTemplates: vi.fn().mockResolvedValue([]),
|
||||
substituteTemplate: vi.fn().mockResolvedValue({ subject: '', body: '' }),
|
||||
uploadAttachment: vi.fn(),
|
||||
replaceSignatureVariables: (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: () => ({ user: null, currentTenant: null }),
|
||||
}));
|
||||
|
||||
import { ComposeModal } from '@/components/mail/ComposeModal';
|
||||
|
||||
function renderModal(props: Partial<React.ComponentProps<typeof ComposeModal>> = {}) {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
mode: 'new' as const,
|
||||
accountId: 'acc1',
|
||||
replyToMail: null,
|
||||
forwardMail: null,
|
||||
signatures: [],
|
||||
onSend: vi.fn().mockResolvedValue(undefined),
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
return render(<MemoryRouter><ComposeModal {...defaultProps} {...props} /></MemoryRouter>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ComposeModal validation (RHF + Zod)', () => {
|
||||
it('shows validation error when "to" is empty on submit', async () => {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
const toInput = screen.getByTestId('compose-to');
|
||||
const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
|
||||
expect(errorEl).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email in "to" field', async () => {
|
||||
renderModal();
|
||||
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'not-an-email' } });
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
const toInput = screen.getByTestId('compose-to');
|
||||
const errorEl = toInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
|
||||
expect(errorEl).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows validation error when subject is empty', async () => {
|
||||
renderModal();
|
||||
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } });
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
const subjectInput = screen.getByTestId('compose-subject');
|
||||
const errorEl = subjectInput.parentElement?.querySelector('.text-danger-500, .text-danger-600, .text-red');
|
||||
expect(errorEl).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSend when form is valid', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(undefined);
|
||||
renderModal({ onSend });
|
||||
fireEvent.change(screen.getByTestId('compose-to'), { target: { value: 'valid@example.com' } });
|
||||
fireEvent.change(screen.getByTestId('compose-subject'), { target: { value: 'Test Subject' } });
|
||||
fireEvent.click(screen.getByTestId('compose-send'));
|
||||
await waitFor(() => {
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('@/api/mail', () => ({
|
||||
fetchAccounts: vi.fn().mockResolvedValue([]),
|
||||
createAccount: vi.fn().mockResolvedValue({ id: 'a1', email: 'test@example.com', display_name: '', imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 587, is_shared: false, is_active: true }),
|
||||
deleteAccount: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
triggerSync: vi.fn(),
|
||||
updateAccount: vi.fn(),
|
||||
fetchFolders: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/mail/SignatureManager', () => ({ SignatureManager: () => null }));
|
||||
vi.mock('@/components/mail/RuleEditor', () => ({ RuleEditor: () => null }));
|
||||
vi.mock('@/components/mail/LabelManager', () => ({ LabelManager: () => null }));
|
||||
vi.mock('@/components/mail/VacationResponder', () => ({ VacationResponder: () => null }));
|
||||
vi.mock('@/components/mail/PgpSettings', () => ({ PgpSettings: () => null }));
|
||||
|
||||
import { MailSettingsPage } from '@/pages/MailSettings';
|
||||
import { createAccount } from '@/api/mail';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('MailSettings account form validation (RHF + Zod)', () => {
|
||||
it('shows validation error when email is empty', async () => {
|
||||
render(<MemoryRouter><MailSettingsPage /></MemoryRouter>);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
// Click add account button
|
||||
const addBtn = screen.getByTestId('add-account-btn');
|
||||
fireEvent.click(addBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-account-form')).toBeInTheDocument();
|
||||
});
|
||||
// Submit empty form
|
||||
const form = screen.getByTestId('add-account-form').querySelector('form');
|
||||
expect(form).toBeTruthy();
|
||||
fireEvent.submit(form!);
|
||||
await waitFor(() => {
|
||||
const errors = screen.getAllByText(/erforderlich|required/i);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createAccount when form is valid', async () => {
|
||||
render(<MemoryRouter><MailSettingsPage /></MemoryRouter>);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mail-settings-page')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
const addBtn = screen.getByTestId('add-account-btn');
|
||||
fireEvent.click(addBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-account-form')).toBeInTheDocument();
|
||||
});
|
||||
const form = screen.getByTestId('add-account-form').querySelector('form');
|
||||
const inputs = form!.querySelectorAll('input');
|
||||
// Fill email, imap_host, smtp_host, password
|
||||
fireEvent.change(inputs[0], { target: { value: 'test@example.com' } }); // email
|
||||
fireEvent.change(inputs[3], { target: { value: 'imap.example.com' } }); // imap_host
|
||||
fireEvent.change(inputs[5], { target: { value: 'smtp.example.com' } }); // smtp_host
|
||||
fireEvent.change(inputs[7], { target: { value: 'password123' } }); // password
|
||||
fireEvent.submit(form!);
|
||||
await waitFor(() => {
|
||||
expect(createAccount).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@example.com', imap_host: 'imap.example.com', smtp_host: 'smtp.example.com' }));
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -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: '€' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/api/tags', () => ({
|
||||
fetchTags: vi.fn().mockResolvedValue([]),
|
||||
assignTag: vi.fn().mockResolvedValue({}),
|
||||
unassignTag: vi.fn().mockResolvedValue({}),
|
||||
createTag: vi.fn().mockResolvedValue({ id: 't1', name: 'NewTag', color: '#3B82F6' }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { TagPicker } from '@/components/tags/TagPicker';
|
||||
import { createTag } from '@/api/tags';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('TagPicker validation (RHF + Zod)', () => {
|
||||
it('shows validation error when tag name is empty', async () => {
|
||||
render(<TagPicker entityType="contact" entityId="e1" />);
|
||||
// Wait for loading to finish and find create button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tag-picker')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
// Click create button to show form (German: 'Neues Tag')
|
||||
const createBtn = screen.getByText(/neues tag|create/i);
|
||||
fireEvent.click(createBtn);
|
||||
// Submit empty form
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-tag-form')).toBeInTheDocument();
|
||||
});
|
||||
const form = screen.getByTestId('create-tag-form').querySelector('form');
|
||||
expect(form).toBeTruthy();
|
||||
fireEvent.submit(form!);
|
||||
await waitFor(() => {
|
||||
const errors = screen.getAllByText(/erforderlich|required/i);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createTag when form is valid', async () => {
|
||||
render(<TagPicker entityType="contact" entityId="e1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tag-picker')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
const createBtn = screen.getByText(/neues tag|create/i);
|
||||
fireEvent.click(createBtn);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-tag-form')).toBeInTheDocument();
|
||||
});
|
||||
// Fill in tag name
|
||||
const form = screen.getByTestId('create-tag-form').querySelector('form');
|
||||
const input = form!.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
fireEvent.change(input!, { target: { value: 'Important' } });
|
||||
fireEvent.submit(form!);
|
||||
await waitFor(() => {
|
||||
expect(createTag).toHaveBeenCalledWith(expect.objectContaining({ name: 'Important' }));
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,16 @@
|
||||
/**
|
||||
* AppointmentModal — create/edit an appointment (or task) entry.
|
||||
* Form validation: React Hook Form + Zod.
|
||||
*
|
||||
* Pre-fills start_at / end_at when the caller supplies a `prefillDate`
|
||||
* (used by clicking an empty day cell in MonthView).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, 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 { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -52,6 +56,38 @@ function fromDateTimeLocalValue(v: string): Date | null {
|
||||
return d;
|
||||
}
|
||||
|
||||
// ── Zod Schema ──
|
||||
|
||||
const appointmentSchema = z
|
||||
.object({
|
||||
title: z.string().min(1, 'required'),
|
||||
calendar_id: z.string().min(1, 'required'),
|
||||
start_at: z.string().min(1, 'required'),
|
||||
end_at: z.string().min(1, 'required'),
|
||||
all_day: z.boolean().default(false),
|
||||
priority: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||
subtype: z.enum(['normal', 'follow_up', 'private']).default('normal'),
|
||||
location: z.string().optional().default(''),
|
||||
description: z.string().optional().default(''),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.start_at && data.end_at) {
|
||||
const start = new Date(data.start_at);
|
||||
const end = new Date(data.end_at);
|
||||
if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) {
|
||||
if (end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'endBeforeStart',
|
||||
path: ['end_at'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type AppointmentFormData = z.infer<typeof appointmentSchema>;
|
||||
|
||||
export function AppointmentModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -84,112 +120,105 @@ export function AppointmentModal({
|
||||
return d;
|
||||
}, [entry, initialStart]);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [startAt, setStartAt] = useState<string>(toDateTimeLocalValue(initialStart));
|
||||
const [endAt, setEndAt] = useState<string>(toDateTimeLocalValue(initialEnd));
|
||||
const [allDay, setAllDay] = useState(false);
|
||||
const [priority, setPriority] = useState<EntryPriority>('medium');
|
||||
const [subtype, setSubtype] = useState<EntrySubtype>('normal');
|
||||
const [calendarId, setCalendarId] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AppointmentFormData>({
|
||||
resolver: zodResolver(appointmentSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
calendar_id: '',
|
||||
start_at: toDateTimeLocalValue(initialStart),
|
||||
end_at: toDateTimeLocalValue(initialEnd),
|
||||
all_day: false,
|
||||
priority: 'medium',
|
||||
subtype: 'normal',
|
||||
location: '',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when entry / prefill / open changes
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle(entry?.title ?? '');
|
||||
setDescription(entry?.description ?? '');
|
||||
setLocation(entry?.location ?? '');
|
||||
setStartAt(toDateTimeLocalValue(entry?.start_at ? new Date(entry.start_at) : initialStart));
|
||||
setEndAt(toDateTimeLocalValue(entry?.end_at ? new Date(entry.end_at) : initialEnd));
|
||||
setAllDay(entry?.all_day ?? false);
|
||||
setPriority(entry?.priority ?? 'medium');
|
||||
setSubtype(entry?.subtype ?? 'normal');
|
||||
const fallbackId =
|
||||
entry?.calendar_id ?? defaultCalendarId ?? calendars[0]?.id ?? '';
|
||||
setCalendarId(fallbackId);
|
||||
setError(null);
|
||||
}, [open, entry, defaultCalendarId, calendars, initialStart, initialEnd]);
|
||||
reset({
|
||||
title: entry?.title ?? '',
|
||||
calendar_id: fallbackId,
|
||||
start_at: toDateTimeLocalValue(entry?.start_at ? new Date(entry.start_at) : initialStart),
|
||||
end_at: toDateTimeLocalValue(entry?.end_at ? new Date(entry.end_at) : initialEnd),
|
||||
all_day: entry?.all_day ?? false,
|
||||
priority: entry?.priority ?? 'medium',
|
||||
subtype: entry?.subtype ?? 'normal',
|
||||
location: entry?.location ?? '',
|
||||
description: entry?.description ?? '',
|
||||
});
|
||||
}, [open, entry, defaultCalendarId, calendars, initialStart, initialEnd, reset]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
setError(t('validation.required'));
|
||||
return;
|
||||
}
|
||||
if (!calendarId) {
|
||||
setError(t('calendar.noCalendars'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const startDate = fromDateTimeLocalValue(startAt);
|
||||
const endDate = fromDateTimeLocalValue(endAt);
|
||||
if (isEdit && entry) {
|
||||
const updated = await updateEntry(entry.id, {
|
||||
title,
|
||||
description: description || null,
|
||||
location: location || null,
|
||||
start_at: startDate ? startDate.toISOString() : null,
|
||||
end_at: endDate ? endDate.toISOString() : null,
|
||||
all_day: allDay,
|
||||
priority,
|
||||
subtype,
|
||||
calendar_id: calendarId,
|
||||
});
|
||||
onSaved(updated);
|
||||
} else {
|
||||
const payload: EntryCreatePayload = {
|
||||
calendar_id: calendarId,
|
||||
entry_type: 'appointment',
|
||||
title,
|
||||
description: description || null,
|
||||
location: location || null,
|
||||
start_at: startDate ? startDate.toISOString() : null,
|
||||
end_at: endDate ? endDate.toISOString() : null,
|
||||
all_day: allDay,
|
||||
priority,
|
||||
subtype,
|
||||
status: 'open',
|
||||
};
|
||||
const created = await createEntry(payload);
|
||||
onSaved(created);
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const err = e as { message?: string };
|
||||
setError(err.message ?? t('calendar.errorSave'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
const onSubmit = async (data: AppointmentFormData) => {
|
||||
const startDate = fromDateTimeLocalValue(data.start_at);
|
||||
const endDate = fromDateTimeLocalValue(data.end_at);
|
||||
if (isEdit && entry) {
|
||||
const updated = await updateEntry(entry.id, {
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
location: data.location || null,
|
||||
start_at: startDate ? startDate.toISOString() : null,
|
||||
end_at: endDate ? endDate.toISOString() : null,
|
||||
all_day: data.all_day,
|
||||
priority: data.priority,
|
||||
subtype: data.subtype,
|
||||
calendar_id: data.calendar_id,
|
||||
});
|
||||
onSaved(updated);
|
||||
} else {
|
||||
const payload: EntryCreatePayload = {
|
||||
calendar_id: data.calendar_id,
|
||||
entry_type: 'appointment',
|
||||
title: data.title,
|
||||
description: data.description || null,
|
||||
location: data.location || null,
|
||||
start_at: startDate ? startDate.toISOString() : null,
|
||||
end_at: endDate ? endDate.toISOString() : null,
|
||||
all_day: data.all_day,
|
||||
priority: data.priority,
|
||||
subtype: data.subtype,
|
||||
status: 'open',
|
||||
};
|
||||
const created = await createEntry(payload);
|
||||
onSaved(created);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('calendar.deleteEntryConfirm'))) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteEntry(entry.id);
|
||||
onDeleted(entry.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const err = e as { message?: string };
|
||||
setError(err.message ?? t('calendar.errorSave'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
console.error(err.message ?? t('calendar.errorSave'));
|
||||
}
|
||||
};
|
||||
|
||||
const calendarOptions = useMemo(
|
||||
() => [
|
||||
...calendars.map((c) => ({ value: c.id, label: c.name })),
|
||||
],
|
||||
() => [...calendars.map((c) => ({ value: c.id, label: c.name }))],
|
||||
[calendars]
|
||||
);
|
||||
|
||||
const errorMsg = (key: string | undefined) => {
|
||||
if (!key) return undefined;
|
||||
if (key === 'required') return t('validation.required');
|
||||
if (key === 'endBeforeStart') return t('calendar.endBeforeStart', 'End must be after start');
|
||||
return key;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -197,14 +226,14 @@ export function AppointmentModal({
|
||||
title={isEdit ? t('calendar.editAppointment') : t('calendar.newAppointment')}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4" data-testid="appointment-modal">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" data-testid="appointment-modal">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('calendar.appointmentTitle')} *
|
||||
</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
{...register('title')}
|
||||
error={errorMsg(errors.title?.message)}
|
||||
placeholder={t('calendar.appointmentTitle')}
|
||||
data-testid="appointment-title"
|
||||
/>
|
||||
@@ -215,8 +244,8 @@ export function AppointmentModal({
|
||||
{t('calendar.appointmentCalendar')} *
|
||||
</label>
|
||||
<Select
|
||||
value={calendarId}
|
||||
onChange={(e) => setCalendarId(e.target.value)}
|
||||
{...register('calendar_id')}
|
||||
error={errorMsg(errors.calendar_id?.message)}
|
||||
options={calendarOptions}
|
||||
data-testid="appointment-calendar"
|
||||
/>
|
||||
@@ -229,8 +258,8 @@ export function AppointmentModal({
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startAt}
|
||||
onChange={(e) => setStartAt(e.target.value)}
|
||||
{...register('start_at')}
|
||||
error={errorMsg(errors.start_at?.message)}
|
||||
data-testid="appointment-start"
|
||||
/>
|
||||
</div>
|
||||
@@ -240,8 +269,8 @@ export function AppointmentModal({
|
||||
</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={endAt}
|
||||
onChange={(e) => setEndAt(e.target.value)}
|
||||
{...register('end_at')}
|
||||
error={errorMsg(errors.end_at?.message)}
|
||||
data-testid="appointment-end"
|
||||
/>
|
||||
</div>
|
||||
@@ -251,8 +280,7 @@ export function AppointmentModal({
|
||||
<input
|
||||
id="appointment-allday"
|
||||
type="checkbox"
|
||||
checked={allDay}
|
||||
onChange={(e) => setAllDay(e.target.checked)}
|
||||
{...register('all_day')}
|
||||
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
data-testid="appointment-allday"
|
||||
/>
|
||||
@@ -266,8 +294,7 @@ export function AppointmentModal({
|
||||
{t('calendar.appointmentLocation')}
|
||||
</label>
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
{...register('location')}
|
||||
placeholder={t('calendar.appointmentLocation')}
|
||||
data-testid="appointment-location"
|
||||
/>
|
||||
@@ -278,8 +305,7 @@ export function AppointmentModal({
|
||||
{t('calendar.appointmentDescription')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
{...register('description')}
|
||||
rows={3}
|
||||
className="w-full rounded-md border-secondary-300 focus:border-primary-500 focus:ring-primary-500"
|
||||
data-testid="appointment-description"
|
||||
@@ -292,8 +318,7 @@ export function AppointmentModal({
|
||||
{t('calendar.appointmentPriority')}
|
||||
</label>
|
||||
<Select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as EntryPriority)}
|
||||
{...register('priority')}
|
||||
options={[
|
||||
{ value: 'low', label: t('calendar.priority.low') },
|
||||
{ value: 'medium', label: t('calendar.priority.medium') },
|
||||
@@ -307,8 +332,7 @@ export function AppointmentModal({
|
||||
{t('calendar.appointmentSubtype')}
|
||||
</label>
|
||||
<Select
|
||||
value={subtype}
|
||||
onChange={(e) => setSubtype(e.target.value as EntrySubtype)}
|
||||
{...register('subtype')}
|
||||
options={[
|
||||
{ value: 'normal', label: t('calendar.subtype.normal') },
|
||||
{ value: 'follow_up', label: t('calendar.subtype.follow_up') },
|
||||
@@ -319,9 +343,9 @@ export function AppointmentModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{(errors.title || errors.calendar_id || errors.start_at || errors.end_at) && (
|
||||
<div className="text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2" data-testid="appointment-error">
|
||||
{error}
|
||||
{errorMsg(errors.title?.message) || errorMsg(errors.calendar_id?.message) || errorMsg(errors.start_at?.message) || errorMsg(errors.end_at?.message)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -331,28 +355,29 @@ export function AppointmentModal({
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={submitting}
|
||||
disabled={isSubmitting}
|
||||
data-testid="appointment-delete"
|
||||
type="button"
|
||||
>
|
||||
{t('calendar.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={submitting}>
|
||||
<Button variant="ghost" onClick={onClose} disabled={isSubmitting} type="button">
|
||||
{t('calendar.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={submitting}
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
data-testid="appointment-save"
|
||||
>
|
||||
{t('calendar.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import React, { useState, useEffect, 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 { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -45,12 +48,22 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [shareType, setShareType] = useState<'user' | 'group'>('user');
|
||||
const [shareId, setShareId] = useState('');
|
||||
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
|
||||
const [linkPassword, setLinkPassword] = useState('');
|
||||
const [linkExpiry, setLinkExpiry] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ── Share form (RHF + Zod) ──
|
||||
const shareSchema = z.object({
|
||||
shareId: z.string().min(1, 'required'),
|
||||
});
|
||||
type ShareFormData = z.infer<typeof shareSchema>;
|
||||
|
||||
const { register: registerShare, handleSubmit: handleSubmitShare, reset: resetShare, formState: { errors: shareErrors } } = useForm<ShareFormData>({
|
||||
resolver: zodResolver(shareSchema),
|
||||
defaultValues: { shareId: '' },
|
||||
});
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
@@ -69,18 +82,18 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
||||
}
|
||||
}, [open, file, loadData]);
|
||||
|
||||
const handleAddShare = useCallback(async () => {
|
||||
if (!file || !shareId) return;
|
||||
const handleAddShare = useCallback(async (data: ShareFormData) => {
|
||||
if (!file) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: { user_id?: string; group_id?: string; permission: 'read' | 'write' } = {
|
||||
permission: sharePermission,
|
||||
};
|
||||
if (shareType === 'user') payload.user_id = shareId;
|
||||
else payload.group_id = shareId;
|
||||
if (shareType === 'user') payload.user_id = data.shareId;
|
||||
else payload.group_id = data.shareId;
|
||||
await shareFile(file.id, payload);
|
||||
toast.success(t('dms.shared'));
|
||||
setShareId('');
|
||||
resetShare({ shareId: '' });
|
||||
onShared();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
@@ -88,7 +101,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [file, shareId, shareType, sharePermission, toast, t, onShared, loadData]);
|
||||
}, [file, shareType, sharePermission, toast, t, onShared, loadData, resetShare]);
|
||||
|
||||
const handleRemovePermission = useCallback(async (userId: string) => {
|
||||
if (!file) return;
|
||||
@@ -155,6 +168,7 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
||||
{/* Add share section */}
|
||||
<div className="space-y-3" data-testid="share-add-section">
|
||||
<h3 className="text-sm font-semibold text-secondary-900">{t('dms.addShare')}</h3>
|
||||
<form onSubmit={handleSubmitShare(handleAddShare)}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Select
|
||||
label={t('dms.userOrGroup')}
|
||||
@@ -167,8 +181,8 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
||||
/>
|
||||
<Input
|
||||
label={shareType === 'user' ? t('dms.userId') : t('dms.groupId')}
|
||||
value={shareId}
|
||||
onChange={(e) => setShareId(e.target.value)}
|
||||
{...registerShare('shareId')}
|
||||
error={shareErrors.shareId?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder={shareType === 'user' ? t('dms.userId') : t('dms.groupId')}
|
||||
/>
|
||||
<Select
|
||||
@@ -182,13 +196,14 @@ export function ShareDialog({ open, file, onClose, onShared }: ShareDialogProps)
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddShare}
|
||||
type="submit"
|
||||
isLoading={submitting}
|
||||
disabled={!shareId}
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
>
|
||||
{t('dms.addShare')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Current permissions */}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* Compose modal — rich text editor (TipTap) with template insert.
|
||||
* Used for new mail, reply, and forward.
|
||||
* Form validation: React Hook Form + Zod.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -31,6 +35,38 @@ export interface ComposeModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── Zod Schema ──
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function validateEmailList(val: string, ctx: z.RefinementCtx, field: string, required: boolean) {
|
||||
const emails = val.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (required && emails.length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'required', path: [field] });
|
||||
return;
|
||||
}
|
||||
for (const email of emails) {
|
||||
if (!emailRegex.test(email)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalidEmail', path: [field] });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const composeSchema = z.object({
|
||||
to: z.string().default(''),
|
||||
cc: z.string().optional().default(''),
|
||||
bcc: z.string().optional().default(''),
|
||||
subject: z.string().min(1, 'required'),
|
||||
body: z.string().default(''),
|
||||
}).superRefine((data, ctx) => {
|
||||
validateEmailList(data.to, ctx, 'to', true);
|
||||
if (data.cc) validateEmailList(data.cc, ctx, 'cc', false);
|
||||
if (data.bcc) validateEmailList(data.bcc, ctx, 'bcc', false);
|
||||
});
|
||||
|
||||
type ComposeFormData = z.infer<typeof composeSchema>;
|
||||
|
||||
export function ComposeModal({
|
||||
open,
|
||||
mode,
|
||||
@@ -46,46 +82,61 @@ export function ComposeModal({
|
||||
const { t } = useTranslation();
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const authTenant = useAuthStore((s) => s.currentTenant);
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [bcc, setBcc] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
const [selectedSignatureId, setSelectedSignatureId] = useState('');
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [savingDraft, setSavingDraft] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<ComposeFormData>({
|
||||
resolver: zodResolver(composeSchema),
|
||||
defaultValues: { to: '', cc: '', bcc: '', subject: '', body: '' },
|
||||
});
|
||||
|
||||
const bodyValue = watch('body');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
setTo(replyToMail.from_address);
|
||||
setSubject(replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`);
|
||||
setBody(`\n\n---\n${replyToMail.body_text.slice(0, 200)}`);
|
||||
reset({
|
||||
to: replyToMail.from_address,
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`,
|
||||
body: `\n\n---\n${replyToMail.body_text.slice(0, 200)}`,
|
||||
});
|
||||
} else if (mode === 'forward' && forwardMail) {
|
||||
setTo('');
|
||||
setSubject(forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`);
|
||||
setBody(`\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`);
|
||||
reset({
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
subject: forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`,
|
||||
body: `\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`,
|
||||
});
|
||||
} else if (mode === 'draft' && draftMail) {
|
||||
setTo(draftMail.to_addresses.join(', '));
|
||||
setCc(draftMail.cc_addresses.join(', '));
|
||||
setBcc(draftMail.bcc_addresses.join(', '));
|
||||
setSubject(draftMail.subject);
|
||||
setBody(draftMail.body_html || draftMail.body_text || '');
|
||||
reset({
|
||||
to: draftMail.to_addresses.join(', '),
|
||||
cc: draftMail.cc_addresses.join(', '),
|
||||
bcc: draftMail.bcc_addresses.join(', '),
|
||||
subject: draftMail.subject,
|
||||
body: draftMail.body_html || draftMail.body_text || '',
|
||||
});
|
||||
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
|
||||
} else {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setBcc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
reset({ to: '', cc: '', bcc: '', subject: '', body: '' });
|
||||
setAttachments([]);
|
||||
}
|
||||
}, [open, mode, replyToMail, forwardMail, draftMail, t]);
|
||||
}, [open, mode, replyToMail, forwardMail, draftMail, t, reset]);
|
||||
|
||||
const insertSignature = useCallback((signatureId: string) => {
|
||||
setSelectedSignatureId(signatureId);
|
||||
@@ -96,17 +147,17 @@ export function ComposeModal({
|
||||
{ name: authUser ? `${authUser.first_name} ${authUser.last_name}`.trim() : undefined, email: authUser?.email, role: authUser?.role, first_name: authUser?.first_name, last_name: authUser?.last_name },
|
||||
{ name: authTenant?.name },
|
||||
);
|
||||
setBody((prev) => `${prev}<br/><br/>---<br/>${processedHtml}`);
|
||||
setValue('body', `${bodyValue}<br/><br/>---<br/>${processedHtml}`);
|
||||
}
|
||||
}, [signatures, authUser, authTenant]);
|
||||
}, [signatures, authUser, authTenant, setValue, bodyValue]);
|
||||
|
||||
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
|
||||
setBody(templateBody);
|
||||
setValue('body', templateBody);
|
||||
if (templateSubject) {
|
||||
setSubject(templateSubject);
|
||||
setValue('subject', templateSubject);
|
||||
}
|
||||
setShowTemplatePicker(false);
|
||||
}, []);
|
||||
}, [setValue]);
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
@@ -143,40 +194,20 @@ export function ComposeModal({
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!onSaveDraft) return;
|
||||
setSavingDraft(true);
|
||||
try {
|
||||
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
const payload: MailDraftPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject,
|
||||
body_text: body.replace(/<[^>]*>/g, ''),
|
||||
body_html: body,
|
||||
};
|
||||
await onSaveDraft(payload);
|
||||
} finally {
|
||||
setSavingDraft(false);
|
||||
}
|
||||
}, [to, cc, bcc, subject, body, accountId, onSaveDraft]);
|
||||
const parseEmailList = (val: string): string[] =>
|
||||
val ? val.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!to.trim()) return;
|
||||
const onSendValidated = useCallback(async (data: ComposeFormData) => {
|
||||
setSending(true);
|
||||
try {
|
||||
const toList = to.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ccList = cc ? cc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
const bccList = bcc ? bcc.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
const toList = parseEmailList(data.to);
|
||||
const ccList = data.cc ? parseEmailList(data.cc) : undefined;
|
||||
const bccList = data.bcc ? parseEmailList(data.bcc) : undefined;
|
||||
|
||||
if (mode === 'reply' && replyToMail) {
|
||||
const replyPayload: ReplyPayload = {
|
||||
account_id: accountId,
|
||||
body,
|
||||
body: data.body,
|
||||
is_html: true,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
@@ -187,7 +218,7 @@ export function ComposeModal({
|
||||
const fwdPayload: ForwardPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
body,
|
||||
body: data.body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
};
|
||||
@@ -198,8 +229,8 @@ export function ComposeModal({
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject,
|
||||
body,
|
||||
subject: data.subject,
|
||||
body: data.body,
|
||||
is_html: true,
|
||||
signature_id: selectedSignatureId || null,
|
||||
attachments: attachments.map((a) => a.id),
|
||||
@@ -211,13 +242,43 @@ export function ComposeModal({
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [to, cc, bcc, subject, body, mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
||||
}, [mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, onClose]);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
if (!onSaveDraft) return;
|
||||
const data = watch();
|
||||
setSavingDraft(true);
|
||||
try {
|
||||
const toList = parseEmailList(data.to);
|
||||
const ccList = data.cc ? parseEmailList(data.cc) : [];
|
||||
const bccList = data.bcc ? parseEmailList(data.bcc) : [];
|
||||
const payload: MailDraftPayload = {
|
||||
account_id: accountId,
|
||||
to: toList,
|
||||
cc: ccList,
|
||||
bcc: bccList,
|
||||
subject: data.subject,
|
||||
body_text: data.body.replace(/<[^>]*>/g, ''),
|
||||
body_html: data.body,
|
||||
};
|
||||
await onSaveDraft(payload);
|
||||
} finally {
|
||||
setSavingDraft(false);
|
||||
}
|
||||
}, [onSaveDraft, watch, accountId]);
|
||||
|
||||
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose');
|
||||
|
||||
const errorMsg = (key: string | undefined) => {
|
||||
if (!key) return undefined;
|
||||
if (key === 'required') return t('validation.required');
|
||||
if (key === 'invalidEmail') return t('validation.email');
|
||||
return key;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
|
||||
<div className="space-y-4" data-testid="compose-modal">
|
||||
<form onSubmit={handleSubmit(onSendValidated)} className="space-y-4" data-testid="compose-modal">
|
||||
{/* Template picker toggle (formatting toolbar is in RichTextEditor) */}
|
||||
<div className="flex items-center gap-2" data-testid="compose-toolbar">
|
||||
<button
|
||||
@@ -240,8 +301,8 @@ export function ComposeModal({
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label={t('mail.to')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
{...register('to')}
|
||||
error={errorMsg(errors.to?.message)}
|
||||
placeholder="recipient@example.com"
|
||||
required
|
||||
data-testid="compose-to"
|
||||
@@ -259,14 +320,14 @@ export function ComposeModal({
|
||||
<div className="w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<Input
|
||||
label={t('mail.cc')}
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
{...register('cc')}
|
||||
error={errorMsg(errors.cc?.message)}
|
||||
placeholder="cc@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.bcc')}
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
{...register('bcc')}
|
||||
error={errorMsg(errors.bcc?.message)}
|
||||
placeholder="bcc@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -274,8 +335,8 @@ export function ComposeModal({
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.subject')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
{...register('subject')}
|
||||
error={errorMsg(errors.subject?.message)}
|
||||
placeholder={t('mail.subjectPlaceholder')}
|
||||
data-testid="compose-subject"
|
||||
/>
|
||||
@@ -285,8 +346,8 @@ export function ComposeModal({
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
|
||||
<RichTextEditor
|
||||
content={body}
|
||||
onChange={setBody}
|
||||
content={bodyValue}
|
||||
onChange={(html: string) => setValue('body', html)}
|
||||
placeholder={t('mail.body')}
|
||||
/>
|
||||
</div>
|
||||
@@ -367,11 +428,11 @@ export function ComposeModal({
|
||||
{t('mail.saveDraft')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSend} isLoading={sending} type="button" data-testid="compose-send">
|
||||
<Button type="submit" isLoading={sending} data-testid="compose-send">
|
||||
{t('mail.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
import React, { useState, useEffect, 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 { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
@@ -30,11 +33,23 @@ export function LabelManager() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState(PRESET_COLORS[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MailLabel | null>(null);
|
||||
|
||||
// ── Label form (RHF + Zod) ──
|
||||
const labelSchema = z.object({
|
||||
name: z.string().min(1, 'required'),
|
||||
color: z.string().optional().default(PRESET_COLORS[0]),
|
||||
});
|
||||
type LabelFormData = z.infer<typeof labelSchema>;
|
||||
|
||||
const { register: registerLabel, handleSubmit: handleSubmitLabel, reset: resetLabel, watch: watchLabel, setValue: setLabelValue, formState: { errors: labelErrors } } = useForm<LabelFormData>({
|
||||
resolver: zodResolver(labelSchema),
|
||||
defaultValues: { name: '', color: PRESET_COLORS[0] },
|
||||
});
|
||||
|
||||
const colorValue = watchLabel('color');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchLabels()
|
||||
@@ -50,22 +65,20 @@ export function LabelManager() {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
const handleSave = useCallback(async (data: LabelFormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const label = await createLabel({ name, color });
|
||||
const label = await createLabel({ name: data.name, color: data.color });
|
||||
setLabels((prev) => [...prev, label]);
|
||||
toast.success(t('mail.labelCreated'));
|
||||
setShowForm(false);
|
||||
setName('');
|
||||
setColor(PRESET_COLORS[0]);
|
||||
resetLabel({ name: '', color: PRESET_COLORS[0] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [name, color, toast, t]);
|
||||
}, [toast, t, resetLabel]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
@@ -104,11 +117,11 @@ export function LabelManager() {
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-4" data-testid="label-form">
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={handleSubmitLabel(handleSave)} className="space-y-3">
|
||||
<Input
|
||||
label={t('mail.labelName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
{...registerLabel('name')}
|
||||
error={labelErrors.name?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder={t('mail.labelName')}
|
||||
required
|
||||
/>
|
||||
@@ -119,8 +132,8 @@ export function LabelManager() {
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className={`w-8 h-8 rounded-full ${color === c ? 'ring-2 ring-offset-2 ring-primary-500' : ''}`}
|
||||
onClick={() => setLabelValue('color', c)}
|
||||
className={`w-8 h-8 rounded-full ${colorValue === c ? 'ring-2 ring-offset-2 ring-primary-500' : ''}`}
|
||||
style={{ backgroundColor: c }}
|
||||
aria-label={c}
|
||||
/>
|
||||
@@ -128,10 +141,10 @@ export function LabelManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
import React, { useState, useEffect, 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 { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
@@ -47,13 +50,23 @@ export function RuleEditor({ accountId }: { accountId: string }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [priority, setPriority] = useState(1);
|
||||
const [conditions, setConditions] = useState<RuleCondition[]>([{ type: 'from_contains', value: '' }]);
|
||||
const [actions, setActions] = useState<RuleAction[]>([{ type: 'mark_as_read', value: '' }]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MailRule | null>(null);
|
||||
|
||||
// ── Rule form (RHF + Zod) ──
|
||||
const ruleSchema = z.object({
|
||||
name: z.string().min(1, 'required'),
|
||||
priority: z.coerce.number().int().min(1, 'invalidNumber').default(1),
|
||||
});
|
||||
type RuleFormData = z.infer<typeof ruleSchema>;
|
||||
|
||||
const { register: registerRule, handleSubmit: handleSubmitRule, reset: resetRule, formState: { errors: ruleErrors } } = useForm<RuleFormData>({
|
||||
resolver: zodResolver(ruleSchema),
|
||||
defaultValues: { name: '', priority: 1 },
|
||||
});
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchRules()
|
||||
@@ -93,14 +106,13 @@ export function RuleEditor({ accountId }: { accountId: string }) {
|
||||
setActions((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
const handleSave = useCallback(async (data: RuleFormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const rule = await createRule({
|
||||
name,
|
||||
name: data.name,
|
||||
account_id: accountId,
|
||||
priority,
|
||||
priority: data.priority,
|
||||
is_active: true,
|
||||
conditions,
|
||||
actions,
|
||||
@@ -108,8 +120,7 @@ export function RuleEditor({ accountId }: { accountId: string }) {
|
||||
setRules((prev) => [...prev, rule].sort((a, b) => a.priority - b.priority));
|
||||
toast.success(t('mail.ruleCreated'));
|
||||
setShowForm(false);
|
||||
setName('');
|
||||
setPriority(1);
|
||||
resetRule({ name: '', priority: 1 });
|
||||
setConditions([{ type: 'from_contains', value: '' }]);
|
||||
setActions([{ type: 'mark_as_read', value: '' }]);
|
||||
} catch (err) {
|
||||
@@ -117,7 +128,7 @@ export function RuleEditor({ accountId }: { accountId: string }) {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [name, accountId, priority, conditions, actions, toast, t]);
|
||||
}, [accountId, conditions, actions, toast, t, resetRule]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
@@ -156,19 +167,19 @@ export function RuleEditor({ accountId }: { accountId: string }) {
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-4" data-testid="rule-form">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSubmitRule(handleSave)} className="space-y-4">
|
||||
<Input
|
||||
label={t('mail.ruleName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
{...registerRule('name')}
|
||||
error={ruleErrors.name?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder={t('mail.ruleName')}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.rulePriority')}
|
||||
type="number"
|
||||
value={String(priority)}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
{...registerRule('priority')}
|
||||
error={ruleErrors.priority?.message === 'invalidNumber' ? t('validation.invalidNumber', 'Invalid number') : undefined}
|
||||
/>
|
||||
|
||||
{/* Conditions */}
|
||||
@@ -226,10 +237,10 @@ export function RuleEditor({ accountId }: { accountId: string }) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import React, { useState, useEffect, 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 { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
@@ -41,12 +44,24 @@ export function SignatureManager() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<MailSignature | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [bodyHtml, setBodyHtml] = useState('');
|
||||
const [isDefault, setIsDefault] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<MailSignature | null>(null);
|
||||
|
||||
// ── Signature form (RHF + Zod) ──
|
||||
const sigSchema = z.object({
|
||||
name: z.string().min(1, 'required'),
|
||||
body_html: z.string().default(''),
|
||||
is_default: z.boolean().default(false),
|
||||
});
|
||||
type SigFormData = z.infer<typeof sigSchema>;
|
||||
|
||||
const { register: registerSig, handleSubmit: handleSubmitSig, reset: resetSig, watch: watchSig, setValue: setSigValue, formState: { errors: sigErrors } } = useForm<SigFormData>({
|
||||
resolver: zodResolver(sigSchema),
|
||||
defaultValues: { name: '', body_html: '', is_default: false },
|
||||
});
|
||||
|
||||
const bodyHtmlValue = watchSig('body_html');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchSignatures()
|
||||
@@ -64,30 +79,25 @@ export function SignatureManager() {
|
||||
|
||||
const handleNew = () => {
|
||||
setEditing(null);
|
||||
setName('');
|
||||
setBodyHtml('');
|
||||
setIsDefault(false);
|
||||
resetSig({ name: '', body_html: '', is_default: false });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (sig: MailSignature) => {
|
||||
setEditing(sig);
|
||||
setName(sig.name);
|
||||
setBodyHtml(sig.body_html);
|
||||
setIsDefault(sig.is_default);
|
||||
resetSig({ name: sig.name, body_html: sig.body_html, is_default: sig.is_default });
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
const handleSave = useCallback(async (data: SigFormData) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await updateSignature(editing.id, { name, body_html: bodyHtml, is_default: isDefault });
|
||||
const updated = await updateSignature(editing.id, { name: data.name, body_html: data.body_html, is_default: data.is_default });
|
||||
setSignatures((prev) => prev.map((s) => (s.id === editing.id ? updated : s)));
|
||||
toast.success(t('mail.signatureUpdated'));
|
||||
} else {
|
||||
const created = await createSignature({ name, body_html: bodyHtml, is_default: isDefault });
|
||||
const created = await createSignature({ name: data.name, body_html: data.body_html, is_default: data.is_default });
|
||||
setSignatures((prev) => [...prev, created]);
|
||||
toast.success(t('mail.signatureCreated'));
|
||||
}
|
||||
@@ -97,7 +107,7 @@ export function SignatureManager() {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editing, name, bodyHtml, isDefault, toast, t]);
|
||||
}, [editing, toast, t]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
@@ -136,11 +146,11 @@ export function SignatureManager() {
|
||||
|
||||
{showForm && (
|
||||
<Card className="mb-4" data-testid="signature-form">
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={handleSubmitSig(handleSave)} className="space-y-3">
|
||||
<Input
|
||||
label={t('mail.signatureName')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
{...registerSig('name')}
|
||||
error={sigErrors.name?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder={t('mail.signatureName')}
|
||||
required
|
||||
/>
|
||||
@@ -152,7 +162,7 @@ export function SignatureManager() {
|
||||
<button
|
||||
key={v.token}
|
||||
type="button"
|
||||
onClick={() => setBodyHtml((prev) => `${prev}${v.token}`)}
|
||||
onClick={() => setSigValue('body_html', `${bodyHtmlValue}${v.token}`)}
|
||||
className="inline-flex items-center px-2 py-0.5 text-xs rounded border border-secondary-300 bg-secondary-50 hover:bg-secondary-100 text-secondary-700"
|
||||
title={v.description}
|
||||
>
|
||||
@@ -161,20 +171,20 @@ export function SignatureManager() {
|
||||
))}
|
||||
</div>
|
||||
<RichTextEditor
|
||||
content={bodyHtml}
|
||||
onChange={setBodyHtml}
|
||||
content={bodyHtmlValue}
|
||||
onChange={(html: string) => setSigValue('body_html', html)}
|
||||
placeholder="<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} className="rounded" />
|
||||
<input type="checkbox" {...registerSig('is_default')} className="rounded" />
|
||||
{t('mail.defaultSignature')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setShowForm(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,53 +1,90 @@
|
||||
/**
|
||||
* Vacation responder — toggle + date range + auto-reply text.
|
||||
* Form validation: React Hook Form + Zod.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { 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 { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { configureVacation, type VacationPayload } from '@/api/mail';
|
||||
|
||||
// ── Zod Schema ──
|
||||
const vacationSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
start_date: z.string().optional().default(''),
|
||||
end_date: z.string().optional().default(''),
|
||||
subject: z.string().optional().default(''),
|
||||
body: z.string().optional().default(''),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.enabled && data.start_date && data.end_date) {
|
||||
const start = new Date(data.start_date);
|
||||
const end = new Date(data.end_date);
|
||||
if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) {
|
||||
if (end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'endBeforeStart',
|
||||
path: ['end_date'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type VacationFormData = z.infer<typeof vacationSchema>;
|
||||
|
||||
export function VacationResponder({ accountId }: { accountId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<VacationFormData>({
|
||||
resolver: zodResolver(vacationSchema),
|
||||
defaultValues: { enabled: false, start_date: '', end_date: '', subject: '', body: '' },
|
||||
});
|
||||
|
||||
const enabled = watch('enabled');
|
||||
|
||||
const onSubmit = useCallback(async (data: VacationFormData) => {
|
||||
try {
|
||||
const payload: VacationPayload = {
|
||||
enabled,
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
subject,
|
||||
body,
|
||||
enabled: data.enabled,
|
||||
start_date: data.start_date || null,
|
||||
end_date: data.end_date || null,
|
||||
subject: data.subject,
|
||||
body: data.body,
|
||||
};
|
||||
await configureVacation(payload);
|
||||
toast.success(t('mail.vacationSaved'));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [enabled, startDate, endDate, subject, body, toast, t]);
|
||||
}, [toast, t]);
|
||||
|
||||
const errorMsg = (key: string | undefined) => {
|
||||
if (!key) return undefined;
|
||||
if (key === 'endBeforeStart') return t('calendar.endBeforeStart', 'End must be after start');
|
||||
return key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="vacation-responder">
|
||||
<Card title={t('mail.vacationResponder')}>
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
{...register('enabled')}
|
||||
className="w-5 h-5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
data-testid="vacation-toggle"
|
||||
/>
|
||||
@@ -60,27 +97,24 @@ export function VacationResponder({ accountId }: { accountId: string }) {
|
||||
<Input
|
||||
label={t('mail.vacationStart')}
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
{...register('start_date')}
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.vacationEnd')}
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
{...register('end_date')}
|
||||
error={errorMsg(errors.end_date?.message)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.vacationSubject')}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
{...register('subject')}
|
||||
placeholder={t('mail.vacationSubjectPlaceholder')}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.vacationBody')}</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
{...register('body')}
|
||||
className="w-full min-h-32 border border-secondary-300 rounded-md p-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder={t('mail.vacationBodyPlaceholder')}
|
||||
data-testid="vacation-body"
|
||||
@@ -89,11 +123,11 @@ export function VacationResponder({ accountId }: { accountId: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} isLoading={saving} size="sm" data-testid="vacation-save">
|
||||
<Button type="submit" isLoading={isSubmitting} size="sm" data-testid="vacation-save">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import clsx from 'clsx';
|
||||
|
||||
import React, { useState, useEffect, 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 { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -35,10 +38,22 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [newTagColor, setNewTagColor] = useState('#3B82F6');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ── Tag create form (RHF + Zod) ──
|
||||
const tagSchema = z.object({
|
||||
name: z.string().min(1, 'required'),
|
||||
color: z.string().optional().default('#3B82F6'),
|
||||
});
|
||||
type TagFormData = z.infer<typeof tagSchema>;
|
||||
|
||||
const { register: registerTag, handleSubmit: handleSubmitTag, reset: resetTag, watch: watchTag, setValue: setTagValue, formState: { errors: tagErrors } } = useForm<TagFormData>({
|
||||
resolver: zodResolver(tagSchema),
|
||||
defaultValues: { name: '', color: '#3B82F6' },
|
||||
});
|
||||
|
||||
const newTagColor = watchTag('color');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
@@ -95,14 +110,13 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned
|
||||
setSubmitting(false);
|
||||
}, [entityType, entityId, toast, t]);
|
||||
|
||||
const handleCreateTag = useCallback(async () => {
|
||||
if (!newTagName.trim()) return;
|
||||
const handleCreateTag = useCallback(async (data: TagFormData) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tag = await createTag({ name: newTagName.trim(), color: newTagColor });
|
||||
const tag = await createTag({ name: data.name.trim(), color: data.color });
|
||||
setAllTags((prev) => [...prev, tag]);
|
||||
await handleAssign(tag.id);
|
||||
setNewTagName('');
|
||||
resetTag({ name: '', color: '#3B82F6' });
|
||||
setShowCreateForm(false);
|
||||
toast.success(t('tags.createSuccess'));
|
||||
} catch (err) {
|
||||
@@ -110,7 +124,7 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [newTagName, newTagColor, handleAssign, toast, t]);
|
||||
}, [handleAssign, toast, t, resetTag]);
|
||||
|
||||
const colorOptions = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#F97316', '#EC4899', '#6B7280'];
|
||||
|
||||
@@ -195,10 +209,11 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 p-4 border border-secondary-200 rounded-lg" data-testid="create-tag-form">
|
||||
<form onSubmit={handleSubmitTag(handleCreateTag)}>
|
||||
<Input
|
||||
label={t('tags.tagName')}
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
label={t('tags.tagName')}
|
||||
{...registerTag('name')}
|
||||
error={tagErrors.name?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder={t('tags.tagName')}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
@@ -207,7 +222,8 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setNewTagColor(color)}
|
||||
type="button"
|
||||
onClick={() => setTagValue('color', color)}
|
||||
className={clsx(
|
||||
'w-6 h-6 rounded-full transition-transform',
|
||||
newTagColor === color ? 'ring-2 ring-offset-2 ring-secondary-400 scale-110' : ''
|
||||
@@ -220,13 +236,10 @@ export function TagPicker({ entityType, entityId, assignedTags: initialAssigned
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleCreateTag} isLoading={submitting} disabled={!newTagName.trim()}>
|
||||
{t('tags.save')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowCreateForm(false)}>
|
||||
{t('tags.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" type="submit" isLoading={submitting}>{t('tags.save')}</Button>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setShowCreateForm(false)}>{t('tags.cancel')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+24
-15
@@ -7,6 +7,9 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -69,7 +72,6 @@ export function DmsPage() {
|
||||
// Modal state
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [previewFile, setPreviewFile] = useState<DmsFile | null>(null);
|
||||
const [shareFile, setShareFile] = useState<DmsFile | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DmsFile | null>(null);
|
||||
@@ -77,6 +79,17 @@ export function DmsPage() {
|
||||
const [showBulkMove, setShowBulkMove] = useState(false);
|
||||
const [bulkMoveTarget, setBulkMoveTarget] = useState<string>('');
|
||||
|
||||
// ── Folder create form (RHF + Zod) ──
|
||||
const folderSchema = z.object({
|
||||
name: z.string().min(1, 'required'),
|
||||
});
|
||||
type FolderFormData = z.infer<typeof folderSchema>;
|
||||
|
||||
const { register: registerFolder, handleSubmit: handleSubmitFolder, reset: resetFolder, formState: { errors: folderErrors } } = useForm<FolderFormData>({
|
||||
resolver: zodResolver(folderSchema),
|
||||
defaultValues: { name: '' },
|
||||
});
|
||||
|
||||
// Mobile view state
|
||||
const [activeView, setActiveView] = useState<'tree' | 'files' | 'details'>('tree');
|
||||
|
||||
@@ -236,16 +249,15 @@ export function DmsPage() {
|
||||
}, [deleteTarget, selectedFile, toast, t]);
|
||||
|
||||
// Handle create folder
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderName.trim()) return;
|
||||
const handleCreateFolder = useCallback(async (data: FolderFormData) => {
|
||||
setSubmittingFolder(true);
|
||||
try {
|
||||
const folder = await createFolder({
|
||||
name: newFolderName.trim(),
|
||||
name: data.name.trim(),
|
||||
parent_id: selectedFolderId,
|
||||
});
|
||||
setFolders((prev) => [...prev, folder]);
|
||||
setNewFolderName('');
|
||||
resetFolder({ name: '' });
|
||||
setShowNewFolder(false);
|
||||
toast.success(t('dms.createFolder'));
|
||||
} catch (err) {
|
||||
@@ -253,7 +265,7 @@ export function DmsPage() {
|
||||
toast.error(msg);
|
||||
}
|
||||
setSubmittingFolder(false);
|
||||
}, [newFolderName, selectedFolderId, toast, t]);
|
||||
}, [selectedFolderId, toast, t, resetFolder]);
|
||||
|
||||
// Handle upload complete
|
||||
const handleUploadComplete = useCallback(() => {
|
||||
@@ -469,25 +481,22 @@ export function DmsPage() {
|
||||
|
||||
{/* New folder form */}
|
||||
{showNewFolder && (
|
||||
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="new-folder-form">
|
||||
<form onSubmit={handleSubmitFolder(handleCreateFolder)} className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="new-folder-form">
|
||||
<div className="flex items-center gap-3 max-w-md">
|
||||
<Input
|
||||
label={t('dms.folderName')}
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
{...registerFolder('name')}
|
||||
error={folderErrors.name?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder={t('dms.folderName')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFolder();
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={handleCreateFolder} isLoading={submittingFolder}>
|
||||
<Button size="sm" type="submit" isLoading={submittingFolder}>
|
||||
{t('dms.createFolder')}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => { setShowNewFolder(false); setNewFolderName(''); }}>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => { setShowNewFolder(false); resetFolder({ name: '' }); }}>
|
||||
{t('dms.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Upload dropzone */}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -37,16 +40,24 @@ export function MailSettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
||||
const [showAddAccount, setShowAddAccount] = useState(false);
|
||||
const [newAccount, setNewAccount] = useState<CreateAccountPayload>({
|
||||
email: '',
|
||||
display_name: '',
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
is_shared: false,
|
||||
|
||||
// ── Account create form (RHF + Zod) ──
|
||||
const accountSchema = z.object({
|
||||
email: z.string().min(1, 'required').email('invalidEmail'),
|
||||
display_name: z.string().optional().default(''),
|
||||
username: z.string().optional().default(''),
|
||||
imap_host: z.string().min(1, 'required'),
|
||||
imap_port: z.coerce.number().int().min(1, 'invalidNumber').max(65535, 'invalidNumber').default(993),
|
||||
smtp_host: z.string().min(1, 'required'),
|
||||
smtp_port: z.coerce.number().int().min(1, 'invalidNumber').max(65535, 'invalidNumber').default(587),
|
||||
password: z.string().min(1, 'required'),
|
||||
is_shared: z.boolean().default(false),
|
||||
});
|
||||
type AccountFormData = z.infer<typeof accountSchema>;
|
||||
|
||||
const { register: registerAccount, handleSubmit: handleSubmitAccount, reset: resetAccount, formState: { errors: accountErrors, isSubmitting: accountSubmitting } } = useForm<AccountFormData>({
|
||||
resolver: zodResolver(accountSchema),
|
||||
defaultValues: { email: '', display_name: '', username: '', imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 587, password: '', is_shared: false },
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
@@ -75,31 +86,17 @@ export function MailSettingsPage() {
|
||||
|
||||
useEffect(() => { loadAccounts(); }, [loadAccounts]);
|
||||
|
||||
const handleCreateAccount = useCallback(async () => {
|
||||
if (!newAccount.email.trim() || !newAccount.password.trim()) return;
|
||||
setSaving(true);
|
||||
const handleCreateAccount = useCallback(async (data: AccountFormData) => {
|
||||
try {
|
||||
const acc = await createAccount(newAccount);
|
||||
const acc = await createAccount(data);
|
||||
setAccounts((prev) => [...prev, acc]);
|
||||
toast.success(t('mail.accountCreated'));
|
||||
setShowAddAccount(false);
|
||||
setNewAccount({
|
||||
email: '',
|
||||
display_name: '',
|
||||
imap_host: '',
|
||||
imap_port: 993,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
username: '',
|
||||
password: '',
|
||||
is_shared: false,
|
||||
});
|
||||
resetAccount({ email: '', display_name: '', username: '', imap_host: '', imap_port: 993, smtp_host: '', smtp_port: 587, password: '', is_shared: false });
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || err?.detail || 'Save failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [newAccount, toast, t]);
|
||||
}, [toast, t, resetAccount]);
|
||||
|
||||
const handleTestConnection = useCallback(async (accountId: string) => {
|
||||
setTesting(accountId);
|
||||
@@ -218,75 +215,72 @@ export function MailSettingsPage() {
|
||||
|
||||
{showAddAccount && (
|
||||
<Card className="mb-4" data-testid="add-account-form">
|
||||
<div className="space-y-3">
|
||||
<form onSubmit={handleSubmitAccount(handleCreateAccount)} className="space-y-3">
|
||||
<Input
|
||||
label={t('mail.email')}
|
||||
value={newAccount.email}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, email: e.target.value }))}
|
||||
{...registerAccount('email')}
|
||||
error={accountErrors.email?.message === 'required' ? t('validation.required') : accountErrors.email?.message === 'invalidEmail' ? t('validation.email') : undefined}
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.displayName')}
|
||||
value={newAccount.display_name}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, display_name: e.target.value }))}
|
||||
{...registerAccount('display_name')}
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<Input
|
||||
label="Benutzername (IMAP/SMTP)"
|
||||
value={newAccount.username}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, username: e.target.value }))}
|
||||
{...registerAccount('username')}
|
||||
placeholder="Leer lassen für E-Mail-Adresse"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t('mail.imapHost')}
|
||||
value={newAccount.imap_host}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, imap_host: e.target.value }))}
|
||||
{...registerAccount('imap_host')}
|
||||
error={accountErrors.imap_host?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder="imap.example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.imapPort')}
|
||||
type="number"
|
||||
value={String(newAccount.imap_port)}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, imap_port: Number(e.target.value) }))}
|
||||
{...registerAccount('imap_port')}
|
||||
error={accountErrors.imap_port?.message === 'invalidNumber' ? t('validation.invalidNumber', 'Invalid number') : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label={t('mail.smtpHost')}
|
||||
value={newAccount.smtp_host}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, smtp_host: e.target.value }))}
|
||||
{...registerAccount('smtp_host')}
|
||||
error={accountErrors.smtp_host?.message === 'required' ? t('validation.required') : undefined}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t('mail.smtpPort')}
|
||||
type="number"
|
||||
value={String(newAccount.smtp_port)}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, smtp_port: Number(e.target.value) }))}
|
||||
{...registerAccount('smtp_port')}
|
||||
error={accountErrors.smtp_port?.message === 'invalidNumber' ? t('validation.invalidNumber', 'Invalid number') : undefined}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label={t('mail.password')}
|
||||
type="password"
|
||||
value={newAccount.password}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, password: e.target.value }))}
|
||||
{...registerAccount('password')}
|
||||
error={accountErrors.password?.message === 'required' ? t('validation.required') : undefined}
|
||||
required
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newAccount.is_shared || false}
|
||||
onChange={(e) => setNewAccount((prev) => ({ ...prev, is_shared: e.target.checked }))}
|
||||
{...registerAccount('is_shared')}
|
||||
className="rounded"
|
||||
/>
|
||||
Geteiltes Postfach (für alle Tenant-Benutzer sichtbar)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateAccount} isLoading={saving} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddAccount(false)}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" isLoading={accountSubmitting} size="sm">{t('common.save')}</Button>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setShowAddAccount(false)}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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