revert(frontend): Frontend auf letzten funktionierenden Stand 5680179 zurückgesetzt — i18n-Massen-Batch brach Dashboard-Shell in Produktion; Render-Loop-Fix und DSGVO-Antrags-UI liegen sicher in Historie für kontrollierten Wiedereinspiel
This commit is contained in:
@@ -8,11 +8,9 @@ import { useThemeStore } from '@/store/themeStore';
|
|||||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
|
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
|
|
||||||
function QueryClientWrapper({ children }: { children: React.ReactNode }) {
|
function QueryClientWrapper({ children }: { children: React.ReactNode }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [queryClient] = React.useState(() => new QueryClient({
|
const [queryClient] = React.useState(() => new QueryClient({
|
||||||
@@ -42,20 +40,18 @@ function QueryClientWrapper({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OfflineBanner() {
|
function OfflineBanner() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const isOnline = useOnlineStatus();
|
const isOnline = useOnlineStatus();
|
||||||
|
|
||||||
if (isOnline) return null;
|
if (isOnline) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 right-0 z-[200] bg-warning-500 text-white text-center py-2 px-4 text-sm font-medium shadow-md">
|
<div className="fixed top-0 left-0 right-0 z-[200] bg-warning-500 text-white text-center py-2 px-4 text-sm font-medium shadow-md">
|
||||||
{t('app.siesindofflineänderungenwerdengespeicher')}
|
Sie sind offline. Änderungen werden gespeichert wenn die Verbindung wiederhergestellt ist.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
|
const loadThemeFromStorage = useThemeStore((s) => s.loadFromStorage);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -80,7 +76,7 @@ export default function App() {
|
|||||||
href="#main-content"
|
href="#main-content"
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[300] focus:px-4 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded-md"
|
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[300] focus:px-4 focus:py-2 focus:bg-primary-600 focus:text-white focus:rounded-md"
|
||||||
>
|
>
|
||||||
{t('app.zumhauptinhaltspringen')}
|
Zum Hauptinhalt springen
|
||||||
</a>
|
</a>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* PWA Install Prompt tests — Task 5.24.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
|
||||||
|
import { getNotificationPermission, isPWAInstalled } from '@/utils/notifications';
|
||||||
|
|
||||||
|
// Mock i18n
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('PWAInstallPrompt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when no beforeinstallprompt event fires', () => {
|
||||||
|
render(<PWAInstallPrompt />);
|
||||||
|
expect(screen.queryByTestId('pwa-install-prompt')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows install prompt when beforeinstallprompt fires', async () => {
|
||||||
|
render(<PWAInstallPrompt />);
|
||||||
|
|
||||||
|
const event = new Event('beforeinstallprompt');
|
||||||
|
Object.assign(event, {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
userChoice: Promise.resolve({ outcome: 'accepted' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('pwa-install-prompt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId('pwa-install-btn')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('pwa-dismiss-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides when dismiss button is clicked', async () => {
|
||||||
|
render(<PWAInstallPrompt />);
|
||||||
|
|
||||||
|
const event = new Event('beforeinstallprompt');
|
||||||
|
Object.assign(event, {
|
||||||
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
|
userChoice: Promise.resolve({ outcome: 'dismissed' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('pwa-install-prompt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('pwa-dismiss-btn'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('pwa-install-prompt')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not show again after dismiss (localStorage)
|
||||||
|
expect(localStorage.getItem('leocrm_pwa_install_dismissed')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show when already dismissed', () => {
|
||||||
|
localStorage.setItem('leocrm_pwa_install_dismissed', '1');
|
||||||
|
render(<PWAInstallPrompt />);
|
||||||
|
|
||||||
|
const event = new Event('beforeinstallprompt');
|
||||||
|
Object.assign(event, {
|
||||||
|
prompt: vi.fn(),
|
||||||
|
userChoice: Promise.resolve({ outcome: 'dismissed' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('pwa-install-prompt')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notification helpers', () => {
|
||||||
|
it('getNotificationPermission returns unsupported when Notification API missing', () => {
|
||||||
|
const original = (window as any).Notification;
|
||||||
|
delete (window as any).Notification;
|
||||||
|
expect(getNotificationPermission()).toBe('unsupported');
|
||||||
|
(window as any).Notification = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isPWAInstalled returns false in browser mode', () => {
|
||||||
|
expect(isPWAInstalled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { TasksPage } from '@/pages/Tasks';
|
import { TasksPage } from '@/pages/Tasks';
|
||||||
import * as tasksApi from '@/api/tasks';
|
import * as tasksApi from '@/api/tasks';
|
||||||
|
|
||||||
@@ -12,8 +12,6 @@ vi.mock('react-i18next', () => ({
|
|||||||
useTranslation: () => ({ t: (key: string) => key }),
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
|
||||||
|
|
||||||
// Mock toast
|
// Mock toast
|
||||||
vi.mock('@/components/ui/Toast', () => ({
|
vi.mock('@/components/ui/Toast', () => ({
|
||||||
useToast: () => ({
|
useToast: () => ({
|
||||||
@@ -116,24 +114,20 @@ vi.mock('@/api/tasks', () => ({
|
|||||||
describe('TasksPage', () => {
|
describe('TasksPage', () => {
|
||||||
it('renders the tasks page with header', () => {
|
it('renders the tasks page with header', () => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getAllByTestId('tasks-page')[0]).toBeInTheDocument();
|
expect(screen.getByTestId('tasks-page')).toBeInTheDocument();
|
||||||
// Header + Detail-Karte beide nutzen tasks.title seit 3-Spalten-Layout
|
expect(screen.getByText('tasks.title')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('tasks.title').length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders task items from API', () => {
|
it('renders task items from API', () => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
expect(screen.getAllByText('Test Task').length).toBeGreaterThan(0);
|
expect(screen.getByText('Test Task')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('Test description').length).toBeGreaterThan(0);
|
expect(screen.getByText('Test description')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens create modal when create button is clicked', () => {
|
it('opens create modal when create button is clicked', () => {
|
||||||
render(<TasksPage />);
|
render(<TasksPage />);
|
||||||
// Seit UI-Overhaul Phase 4 lebt der Create-Button im PluginToolbarStore,
|
const createBtn = screen.getByText('tasks.create');
|
||||||
// nicht mehr als inline gerenderter Button.
|
fireEvent.click(createBtn);
|
||||||
const item = usePluginToolbarStore.getState().items.find((i) => i.id === 'new-task');
|
|
||||||
expect(item).toBeDefined();
|
|
||||||
act(() => { item!.onClick(); });
|
|
||||||
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
expect(screen.getByTestId('modal')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('task-title-input')).toBeInTheDocument();
|
expect(screen.getByTestId('task-title-input')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,14 +19,6 @@ import ContactCardBlock from '@/components/comm/blocks/ContactCardBlock';
|
|||||||
import MiniAppBlock from '@/components/comm/blocks/MiniAppBlock';
|
import MiniAppBlock from '@/components/comm/blocks/MiniAppBlock';
|
||||||
import type { MessageBlock } from '@/store/commStore';
|
import type { MessageBlock } from '@/store/commStore';
|
||||||
|
|
||||||
// Mock apiClient für MiniAppBlock (fetcht Mini-App-Definitionen on mount)
|
|
||||||
const miniappsMock = [
|
|
||||||
{ app_id: 'my-mini-app', name: 'My Mini App', icon: 'grid', description: 'Demo', plugin_name: 'demo', render_schema: {} },
|
|
||||||
];
|
|
||||||
vi.mock('@/api/client', () => ({
|
|
||||||
apiClient: { get: vi.fn(() => Promise.resolve({ data: miniappsMock })) },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ─── Mock Data Helpers ───
|
// ─── Mock Data Helpers ───
|
||||||
|
|
||||||
function makeBlock(
|
function makeBlock(
|
||||||
@@ -146,11 +138,10 @@ describe('BlockRenderer', () => {
|
|||||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders miniapp block via BlockRenderer', async () => {
|
it('renders miniapp block via BlockRenderer', () => {
|
||||||
const blocks = [makeBlock('b1', 'miniapp', { app_id: 'my-app' })];
|
const blocks = [makeBlock('b1', 'miniapp', { app_id: 'my-app' })];
|
||||||
render(<BlockRenderer blocks={blocks} />);
|
render(<BlockRenderer blocks={blocks} />);
|
||||||
// Async-Fetch: unresolvierte app_id faellt auf den Rohtext zurueck
|
expect(screen.getByText(/Mini-App: my-app/)).toBeInTheDocument();
|
||||||
expect(await screen.findByText('my-app')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,7 +239,7 @@ describe('HtmlBlock', () => {
|
|||||||
});
|
});
|
||||||
const { container } = render(<HtmlBlock block={block} />);
|
const { container } = render(<HtmlBlock block={block} />);
|
||||||
const link = container.querySelector('a');
|
const link = container.querySelector('a');
|
||||||
expect(link?.getAttribute('href') ?? '').not.toContain('javascript:');
|
expect(link?.getAttribute('href')).not.toContain('javascript:');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders div with dangerouslySetInnerHTML', () => {
|
it('renders div with dangerouslySetInnerHTML', () => {
|
||||||
@@ -582,40 +573,42 @@ describe('ContactCardBlock', () => {
|
|||||||
// ─── MiniAppBlock Tests ───
|
// ─── MiniAppBlock Tests ───
|
||||||
|
|
||||||
describe('MiniAppBlock', () => {
|
describe('MiniAppBlock', () => {
|
||||||
it('shows the registered app name when app_id resolves', async () => {
|
it('renders app_id in label', () => {
|
||||||
const block = makeBlock('b1', 'miniapp', { app_id: 'my-mini-app' });
|
const block = makeBlock('b1', 'miniapp', { app_id: 'my-mini-app' });
|
||||||
render(<MiniAppBlock block={block} />);
|
render(<MiniAppBlock block={block} />);
|
||||||
expect(await screen.findByText('My Mini App')).toBeInTheDocument();
|
expect(screen.getByText(/Mini-App: my-mini-app/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the raw app_id when unknown', async () => {
|
it('uses default app_id when not provided', () => {
|
||||||
const block = makeBlock('b1', 'miniapp', { app_id: 'some-other-app' });
|
|
||||||
render(<MiniAppBlock block={block} />);
|
|
||||||
expect(await screen.findByText('some-other-app')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a placeholder label when app_id missing', () => {
|
|
||||||
const block = makeBlock('b1', 'miniapp', {});
|
const block = makeBlock('b1', 'miniapp', {});
|
||||||
render(<MiniAppBlock block={block} />);
|
render(<MiniAppBlock block={block} />);
|
||||||
expect(screen.getByText('Unbekannt')).toBeInTheDocument();
|
expect(screen.getByText(/Mini-App: Unbekannt/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows config key-value pairs when no schema exists', async () => {
|
it('renders info message about mini-apps', () => {
|
||||||
const block = makeBlock('b1', 'miniapp', {
|
|
||||||
app_id: 'test',
|
|
||||||
config: { environment: 'production', region: 'eu-central' },
|
|
||||||
});
|
|
||||||
render(<MiniAppBlock block={block} />);
|
|
||||||
expect(await screen.findByText('environment')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('production')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('region')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('eu-central')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows empty hint when no config', async () => {
|
|
||||||
const block = makeBlock('b1', 'miniapp', { app_id: 'test' });
|
const block = makeBlock('b1', 'miniapp', { app_id: 'test' });
|
||||||
render(<MiniAppBlock block={block} />);
|
render(<MiniAppBlock block={block} />);
|
||||||
await screen.findByText('test');
|
expect(screen.getByText(/Mini-Apps werden in Zukunft/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText('environment')).not.toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
it('shows config details when config is provided', () => {
|
||||||
|
const block = makeBlock('b1', 'miniapp', {
|
||||||
|
app_id: 'test',
|
||||||
|
config: { key: 'value', nested: { data: 123 } },
|
||||||
|
});
|
||||||
|
render(<MiniAppBlock block={block} />);
|
||||||
|
expect(screen.getByText('Konfiguration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show config details when config is empty', () => {
|
||||||
|
const block = makeBlock('b1', 'miniapp', { app_id: 'test', config: {} });
|
||||||
|
render(<MiniAppBlock block={block} />);
|
||||||
|
expect(screen.queryByText('Konfiguration')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show config details when config is not an object', () => {
|
||||||
|
const block = makeBlock('b1', 'miniapp', { app_id: 'test', config: 'not-an-object' });
|
||||||
|
render(<MiniAppBlock block={block} />);
|
||||||
|
expect(screen.queryByText('Konfiguration')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
// ContactEditModal imports UI components that transitively load lucide-react.
|
||||||
|
// To avoid OOM in the vitest worker, we test via a stub that validates the
|
||||||
|
// component's interface contract (props, rendering, callbacks).
|
||||||
|
vi.mock('@/components/contacts/ContactEditModal', () => ({
|
||||||
|
ContactEditModal: ({ open, onClose, contact, onSaved }: any) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const isEdit = !!contact;
|
||||||
|
return (
|
||||||
|
<div data-testid="modal-stub">
|
||||||
|
<h2>{isEdit ? 'Kontakt bearbeiten' : 'Kontakt erstellen'}</h2>
|
||||||
|
<select data-testid="contact-type-select" defaultValue="company">
|
||||||
|
<option value="company">Firmen</option>
|
||||||
|
<option value="person">Personen</option>
|
||||||
|
</select>
|
||||||
|
<input data-testid="contact-name-input" placeholder="Firmenname" />
|
||||||
|
<input data-testid="contact-first-name-input" placeholder="Vorname" style={{ display: 'none' }} />
|
||||||
|
<input data-testid="contact-last-name-input" placeholder="Nachname" style={{ display: 'none' }} />
|
||||||
|
<input data-testid="contact-submit-btn" type="submit" value={isEdit ? 'Speichern' : 'Erstellen'} />
|
||||||
|
<button onClick={onClose}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
contact: null,
|
||||||
|
onSaved: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ContactEditModal', () => {
|
||||||
|
it('renders modal when open', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('modal-stub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} open={false} />);
|
||||||
|
expect(screen.queryByTestId('modal-stub')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders type select', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('contact-type-select')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders name input for company type', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('contact-name-input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders submit button', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('contact-submit-btn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders cancel button', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/abbrechen/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose when cancel button clicked', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByText(/abbrechen/i));
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders create title for new contact', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} />);
|
||||||
|
expect(screen.getByText('Kontakt erstellen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders edit title when editing existing contact', () => {
|
||||||
|
render(<ContactEditModal {...defaultProps} contact={{ id: 'c1', type: 'company' } as any} />);
|
||||||
|
expect(screen.getByText('Kontakt bearbeiten')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,12 +4,6 @@ import { render, screen, act } from '@testing-library/react';
|
|||||||
import { MemoryRouter, Route, Routes, Navigate } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes, Navigate } from 'react-router-dom';
|
||||||
import { ProtectedRoute } from '@/routes/ProtectedRoute';
|
import { ProtectedRoute } from '@/routes/ProtectedRoute';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
function withProviders(ui: React.ReactElement) {
|
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
||||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProtectedTest() {
|
function ProtectedTest() {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +23,7 @@ function ProtectedTest() {
|
|||||||
describe('Router & ProtectedRoute', () => {
|
describe('Router & ProtectedRoute', () => {
|
||||||
it('redirects to /login when not authenticated', () => {
|
it('redirects to /login when not authenticated', () => {
|
||||||
useAuthStore.setState({ isAuthenticated: false, user: null });
|
useAuthStore.setState({ isAuthenticated: false, user: null });
|
||||||
render(withProviders(<ProtectedTest />));
|
render(<ProtectedTest />);
|
||||||
expect(screen.getByTestId('login-page')).toBeInTheDocument();
|
expect(screen.getByTestId('login-page')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -40,7 +34,7 @@ describe('Router & ProtectedRoute', () => {
|
|||||||
user: { id: '1', email: 'test@test.de', first_name: 'Test', last_name: 'User', role: 'admin', avatar_url: null, tenants: [{ id: 't1', name: 'Test Tenant', slug: 'test' }] },
|
user: { id: '1', email: 'test@test.de', first_name: 'Test', last_name: 'User', role: 'admin', avatar_url: null, tenants: [{ id: 't1', name: 'Test Tenant', slug: 'test' }] },
|
||||||
currentTenant: { id: 't1', name: 'Test Tenant', slug: 'test' },
|
currentTenant: { id: 't1', name: 'Test Tenant', slug: 'test' },
|
||||||
});
|
});
|
||||||
render(withProviders(<ProtectedTest />));
|
render(<ProtectedTest />);
|
||||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('login-page')).not.toBeInTheDocument();
|
||||||
useAuthStore.setState({ isAuthenticated: false, user: null });
|
useAuthStore.setState({ isAuthenticated: false, user: null });
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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(),
|
||||||
|
bulkAssignTags: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/Toast', () => ({
|
||||||
|
useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { BulkTagDialog } from '@/components/tags/BulkTagDialog';
|
||||||
|
import { fetchTags, bulkAssignTags } from '@/api/tags';
|
||||||
|
|
||||||
|
const mockTags = [
|
||||||
|
{ id: 't1', name: 'VIP-Kunde', color: '#EF4444', created_by: 'u1', usage_count: 5 },
|
||||||
|
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1', usage_count: 12 },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(fetchTags).mockResolvedValue(mockTags);
|
||||||
|
vi.mocked(bulkAssignTags).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BulkTagDialog', () => {
|
||||||
|
it('renders dialog when open', async () => {
|
||||||
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows selected entity count', async () => {
|
||||||
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2', 'c3']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
|
expect(screen.getByText('3 ausgewaehlte Eintraege')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads and displays available tags', async () => {
|
||||||
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchTags).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /VIP-Kunde/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('button', { name: /Lead/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles tag selection on click', async () => {
|
||||||
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchTags).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const tagBtn = await screen.findByRole('button', { name: /VIP-Kunde/ });
|
||||||
|
fireEvent.click(tagBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('1 Tags auswaehlen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls bulkAssignTags on assign button click', async () => {
|
||||||
|
const onAssigned = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<BulkTagDialog open={true} entityType="contact" entityIds={['c1', 'c2']} onClose={onClose} onAssigned={onAssigned} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchTags).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const tagBtn = await screen.findByRole('button', { name: /VIP-Kunde/ });
|
||||||
|
fireEvent.click(tagBtn);
|
||||||
|
const assignBtn = screen.getByRole('button', { name: 'Tags zuweisen' });
|
||||||
|
fireEvent.click(assignBtn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(bulkAssignTags).toHaveBeenCalledWith({
|
||||||
|
tag_ids: ['t1'],
|
||||||
|
entity_type: 'contact',
|
||||||
|
entity_ids: ['c1', 'c2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
render(<BulkTagDialog open={false} entityType="contact" entityIds={['c1']} onClose={vi.fn()} onAssigned={vi.fn()} />);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
|
const mockFetchTags = vi.fn();
|
||||||
|
const mockAssignTag = vi.fn();
|
||||||
|
const mockUnassignTag = vi.fn();
|
||||||
|
const mockCreateTag = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/api/tags', () => ({
|
||||||
|
fetchTags: (...args: any[]) => mockFetchTags(...args),
|
||||||
|
assignTag: (...args: any[]) => mockAssignTag(...args),
|
||||||
|
unassignTag: (...args: any[]) => mockUnassignTag(...args),
|
||||||
|
createTag: (...args: any[]) => mockCreateTag(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TagPicker } from '@/components/tags/TagPicker';
|
||||||
|
|
||||||
|
const mockTags = [
|
||||||
|
{ id: 't1', name: 'VIP-Kunde', color: '#10B981', created_by: 'u1' },
|
||||||
|
{ id: 't2', name: 'Lead', color: '#3B82F6', created_by: 'u1' },
|
||||||
|
{ id: 't3', name: 'Archiv', color: '#6B7280', created_by: 'u1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFetchTags.mockResolvedValue(mockTags);
|
||||||
|
mockAssignTag.mockResolvedValue({ id: 'a1', tag_id: 't1', entity_type: 'contact', entity_id: 'c1' });
|
||||||
|
mockUnassignTag.mockResolvedValue(undefined);
|
||||||
|
mockCreateTag.mockResolvedValue({ id: 't4', name: 'Neu', color: '#F59E0B', created_by: 'u1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TagPicker', () => {
|
||||||
|
it('renders the tag picker container', async () => {
|
||||||
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
|
expect(screen.getByTestId('tag-picker')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders assigned tags section', async () => {
|
||||||
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
|
expect(screen.getByText('Zugewiesene Tags')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no tags assigned message when empty', async () => {
|
||||||
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Keine Tags zugewiesen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads and displays available tags', async () => {
|
||||||
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchTags).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('available-tags-list')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('VIP-Kunde')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lead')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns a tag when clicking available tag', async () => {
|
||||||
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('VIP-Kunde')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('VIP-Kunde'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAssignTag).toHaveBeenCalledWith({ tag_id: 't1', entity_type: 'contact', entity_id: 'c1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows create tag form when clicking create button', async () => {
|
||||||
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Neues Tag')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByText('Neues Tag'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('create-tag-form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders search input for tags', async () => {
|
||||||
|
render(<TagPicker entityType="contact" entityId="c1" />);
|
||||||
|
expect(screen.getByLabelText('Tag suchen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,6 @@ describe('Automation API Hooks', () => {
|
|||||||
return { data: [], isLoading: false };
|
return { data: [], isLoading: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
mockApiGet.mockResolvedValue([]);
|
|
||||||
useAutomations();
|
useAutomations();
|
||||||
expect(mockApiGet).toHaveBeenCalledWith('/automation');
|
expect(mockApiGet).toHaveBeenCalledWith('/automation');
|
||||||
});
|
});
|
||||||
@@ -62,7 +61,6 @@ describe('Automation API Hooks', () => {
|
|||||||
return { data: [], isLoading: false };
|
return { data: [], isLoading: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
mockApiGet.mockResolvedValue([]);
|
|
||||||
useAgents();
|
useAgents();
|
||||||
expect(mockApiGet).toHaveBeenCalledWith('/agents');
|
expect(mockApiGet).toHaveBeenCalledWith('/agents');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,41 +145,3 @@ export async function updateRetentionPolicy(
|
|||||||
{ days }
|
{ days }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DSAR (GDPR Art. 15/17/20) ───
|
|
||||||
|
|
||||||
export type DsarType = 'access' | 'deletion' | 'rectification';
|
|
||||||
|
|
||||||
export interface DsarRequestResponse {
|
|
||||||
job_id: string;
|
|
||||||
status: string;
|
|
||||||
type: DsarType;
|
|
||||||
user_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queue a DSAR job for a user. Admin only. */
|
|
||||||
export async function submitDsarRequest(
|
|
||||||
userId: string,
|
|
||||||
type: DsarType
|
|
||||||
): Promise<DsarRequestResponse> {
|
|
||||||
return apiPost<DsarRequestResponse>(`/system-settings/dsar/${userId}`, {
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stream the full GDPR data export for a user and trigger a browser download. */
|
|
||||||
export async function downloadDsgvoExport(userId: string, userName?: string): Promise<void> {
|
|
||||||
const response = await apiGet<Blob>(`/system-settings/dsgvo-export/${userId}`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
const blob = new Blob([response], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
const safeName = (userName ?? userId).replace(/[^a-z0-9_-]/gi, '_');
|
|
||||||
link.download = `dsgvo_export_${safeName}.json`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function ActivityFilter({ onFilter, initialValues }: ActivityFilterProps)
|
|||||||
type="text"
|
type="text"
|
||||||
value={user}
|
value={user}
|
||||||
onChange={(e) => setUser(e.target.value)}
|
onChange={(e) => setUser(e.target.value)}
|
||||||
placeholder={t('activityFilter.benutzername')}
|
placeholder="Benutzername"
|
||||||
className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch text-secondary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch text-secondary-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,12 +6,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSignals, useCollectSignals, usePatterns, useDetectPatterns, useProposals, useEvaluateProposal, useActivateProposal, useRollbackProposal, useMeasureImpact } from '@/api/improvement';
|
import { useSignals, useCollectSignals, usePatterns, useDetectPatterns, useProposals, useEvaluateProposal, useActivateProposal, useRollbackProposal, useMeasureImpact } from '@/api/improvement';
|
||||||
import { TrendingUp, AlertCircle, CheckCircle, RefreshCw, Play, RotateCcw, BarChart3 } from 'lucide-react';
|
import { TrendingUp, AlertCircle, CheckCircle, RefreshCw, Play, RotateCcw, BarChart3 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
type SubView = 'signals' | 'patterns' | 'proposals';
|
type SubView = 'signals' | 'patterns' | 'proposals';
|
||||||
|
|
||||||
export function ImprovementPanel() {
|
export function ImprovementPanel() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [subView, setSubView] = useState<SubView>('signals');
|
const [subView, setSubView] = useState<SubView>('signals');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +47,6 @@ export function ImprovementPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SignalsView() {
|
function SignalsView() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading } = useSignals(1, 20);
|
const { data, isLoading } = useSignals(1, 20);
|
||||||
const collectMut = useCollectSignals();
|
const collectMut = useCollectSignals();
|
||||||
const signals = data?.items ?? [];
|
const signals = data?.items ?? [];
|
||||||
@@ -62,7 +59,7 @@ function SignalsView() {
|
|||||||
onClick={() => collectMut.mutate({ limit: 100 })}
|
onClick={() => collectMut.mutate({ limit: 100 })}
|
||||||
disabled={collectMut.isPending}
|
disabled={collectMut.isPending}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
||||||
aria-label={t('improvementPanel.signalesammeln')}
|
aria-label="Signale sammeln"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3 h-3 ${collectMut.isPending ? 'animate-spin' : ''}`} aria-hidden="true" strokeWidth={2} />
|
<RefreshCw className={`w-3 h-3 ${collectMut.isPending ? 'animate-spin' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||||
Sammeln
|
Sammeln
|
||||||
@@ -71,7 +68,7 @@ function SignalsView() {
|
|||||||
|
|
||||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||||
{!isLoading && signals.length === 0 && (
|
{!isLoading && signals.length === 0 && (
|
||||||
<p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinesignaleklickensieaufsammeln')}</p>
|
<p className="text-sm text-secondary-400 text-center py-4">Keine Signale. Klicken Sie auf „Sammeln" um zu starten.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{signals.map(s => (
|
{signals.map(s => (
|
||||||
@@ -95,7 +92,6 @@ function SignalsView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PatternsView() {
|
function PatternsView() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading } = usePatterns(1, 20);
|
const { data, isLoading } = usePatterns(1, 20);
|
||||||
const detectMut = useDetectPatterns();
|
const detectMut = useDetectPatterns();
|
||||||
const patterns = data?.items ?? [];
|
const patterns = data?.items ?? [];
|
||||||
@@ -108,7 +104,7 @@ function PatternsView() {
|
|||||||
onClick={() => detectMut.mutate({ min_occurrences: 2 })}
|
onClick={() => detectMut.mutate({ min_occurrences: 2 })}
|
||||||
disabled={detectMut.isPending}
|
disabled={detectMut.isPending}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-primary-600 hover:bg-primary-50 disabled:opacity-50 min-h-touch"
|
||||||
aria-label={t('improvementPanel.mustererkennen')}
|
aria-label="Muster erkennen"
|
||||||
>
|
>
|
||||||
<TrendingUp className={`w-3 h-3 ${detectMut.isPending ? 'animate-pulse' : ''}`} aria-hidden="true" strokeWidth={2} />
|
<TrendingUp className={`w-3 h-3 ${detectMut.isPending ? 'animate-pulse' : ''}`} aria-hidden="true" strokeWidth={2} />
|
||||||
Erkennen
|
Erkennen
|
||||||
@@ -117,7 +113,7 @@ function PatternsView() {
|
|||||||
|
|
||||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||||
{!isLoading && patterns.length === 0 && (
|
{!isLoading && patterns.length === 0 && (
|
||||||
<p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinemustersammelnsiezuerstsignale')}</p>
|
<p className="text-sm text-secondary-400 text-center py-4">Keine Muster. Sammeln Sie zuerst Signale und klicken Sie dann auf „Erkennen".</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{patterns.map(p => (
|
{patterns.map(p => (
|
||||||
@@ -147,7 +143,6 @@ function PatternsView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProposalsView() {
|
function ProposalsView() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data, isLoading } = useProposals(1, 20);
|
const { data, isLoading } = useProposals(1, 20);
|
||||||
const evalMut = useEvaluateProposal();
|
const evalMut = useEvaluateProposal();
|
||||||
const activateMut = useActivateProposal();
|
const activateMut = useActivateProposal();
|
||||||
@@ -173,7 +168,7 @@ function ProposalsView() {
|
|||||||
|
|
||||||
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
{isLoading && <p className="text-sm text-secondary-400 text-center py-4">Laden...</p>}
|
||||||
{!isLoading && proposals.length === 0 && (
|
{!isLoading && proposals.length === 0 && (
|
||||||
<p className="text-sm text-secondary-400 text-center py-4">{t('improvementPanel.keinevorschläge')}</p>
|
<p className="text-sm text-secondary-400 text-center py-4">Keine Vorschläge.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{proposals.map(p => (
|
{proposals.map(p => (
|
||||||
@@ -196,7 +191,7 @@ function ProposalsView() {
|
|||||||
onClick={() => evalMut.mutate(p.id)}
|
onClick={() => evalMut.mutate(p.id)}
|
||||||
disabled={evalMut.isPending}
|
disabled={evalMut.isPending}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-50 min-h-touch"
|
||||||
aria-label={t('improvementPanel.evaluieren')}
|
aria-label="Evaluieren"
|
||||||
>
|
>
|
||||||
<Play className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
<Play className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||||
Evaluieren
|
Evaluieren
|
||||||
@@ -207,7 +202,7 @@ function ProposalsView() {
|
|||||||
onClick={() => activateMut.mutate(p.id)}
|
onClick={() => activateMut.mutate(p.id)}
|
||||||
disabled={activateMut.isPending}
|
disabled={activateMut.isPending}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-green-600 hover:bg-green-50 disabled:opacity-50 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-green-600 hover:bg-green-50 disabled:opacity-50 min-h-touch"
|
||||||
aria-label={t('improvementPanel.aktivieren')}
|
aria-label="Aktivieren"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
<CheckCircle className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||||
Aktivieren
|
Aktivieren
|
||||||
@@ -219,7 +214,7 @@ function ProposalsView() {
|
|||||||
onClick={() => rollbackMut.mutate({ proposalId: p.id, reason: 'Manual rollback' })}
|
onClick={() => rollbackMut.mutate({ proposalId: p.id, reason: 'Manual rollback' })}
|
||||||
disabled={rollbackMut.isPending}
|
disabled={rollbackMut.isPending}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-danger-600 hover:bg-danger-50 disabled:opacity-50 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-danger-600 hover:bg-danger-50 disabled:opacity-50 min-h-touch"
|
||||||
aria-label={t('improvementPanel.rollback')}
|
aria-label="Rollback"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
<RotateCcw className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||||
Rollback
|
Rollback
|
||||||
@@ -228,7 +223,7 @@ function ProposalsView() {
|
|||||||
onClick={() => measureMut.mutate(p.id)}
|
onClick={() => measureMut.mutate(p.id)}
|
||||||
disabled={measureMut.isPending}
|
disabled={measureMut.isPending}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-purple-600 hover:bg-purple-50 disabled:opacity-50 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium text-purple-600 hover:bg-purple-50 disabled:opacity-50 min-h-touch"
|
||||||
aria-label={t('improvementPanel.impactmessen')}
|
aria-label="Impact messen"
|
||||||
>
|
>
|
||||||
<BarChart3 className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
<BarChart3 className="w-3 h-3" aria-hidden="true" strokeWidth={2} />
|
||||||
Messen
|
Messen
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface SuggestionBadgeProps {
|
interface SuggestionBadgeProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
|
export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const [pulsing, setPulsing] = useState(false);
|
const [pulsing, setPulsing] = useState(false);
|
||||||
|
|
||||||
@@ -33,7 +31,7 @@ export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors min-h-touch min-w-touch"
|
className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors min-h-touch min-w-touch"
|
||||||
title={t('suggestionBadge.kivorschläge')}
|
title="KI Vorschläge"
|
||||||
>
|
>
|
||||||
🤖
|
🤖
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Suggestion } from '@/api/aiProactive';
|
import type { Suggestion } from '@/api/aiProactive';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const typeConfig = {
|
const typeConfig = {
|
||||||
info: { icon: '💡', color: 'blue', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', bar: 'bg-blue-500' },
|
info: { icon: '💡', color: 'blue', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', bar: 'bg-blue-500' },
|
||||||
@@ -16,7 +15,6 @@ interface SuggestionCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardProps) {
|
export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const config = typeConfig[suggestion.suggestion_type] || typeConfig.info;
|
const config = typeConfig[suggestion.suggestion_type] || typeConfig.info;
|
||||||
const confidencePercent = Math.round(suggestion.confidence * 100);
|
const confidencePercent = Math.round(suggestion.confidence * 100);
|
||||||
@@ -34,7 +32,7 @@ export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardP
|
|||||||
<button
|
<button
|
||||||
onClick={() => onDismiss(suggestion.id)}
|
onClick={() => onDismiss(suggestion.id)}
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
title={t('suggestionCard.ignorieren')}
|
title="Ignorieren"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -88,7 +86,7 @@ export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardP
|
|||||||
{/* Acted upon badge */}
|
{/* Acted upon badge */}
|
||||||
{suggestion.is_acted_upon && (
|
{suggestion.is_acted_upon && (
|
||||||
<div className="mt-2 text-xs text-green-600 font-medium flex items-center gap-1">
|
<div className="mt-2 text-xs text-green-600 font-medium flex items-center gap-1">
|
||||||
{t('suggestionCard.ausgeführt')}
|
✓ Ausgeführt
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSuggestions } from '@/api/aiProactive';
|
import { useSuggestions } from '@/api/aiProactive';
|
||||||
import { SuggestionCard } from '@/components/ai/SuggestionCard';
|
import { SuggestionCard } from '@/components/ai/SuggestionCard';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface SuggestionSidebarProps {
|
interface SuggestionSidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,7 +20,6 @@ const filterOptions = [
|
|||||||
* Used inside the AISidebar proactive tab.
|
* Used inside the AISidebar proactive tab.
|
||||||
*/
|
*/
|
||||||
export function SuggestionList() {
|
export function SuggestionList() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { suggestions, connected, dismiss, act } = useSuggestions();
|
const { suggestions, connected, dismiss, act } = useSuggestions();
|
||||||
const [filter, setFilter] = useState<string>('all');
|
const [filter, setFilter] = useState<string>('all');
|
||||||
|
|
||||||
@@ -66,8 +64,8 @@ export function SuggestionList() {
|
|||||||
{filteredSuggestions.length === 0 ? (
|
{filteredSuggestions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-secondary-400">
|
<div className="flex flex-col items-center justify-center h-full text-secondary-400">
|
||||||
<div className="text-4xl mb-3">🤖</div>
|
<div className="text-4xl mb-3">🤖</div>
|
||||||
<p className="text-sm">{t('suggestionSidebar.keinevorschlägevorhanden')}</p>
|
<p className="text-sm">Keine Vorschläge vorhanden</p>
|
||||||
<p className="text-xs mt-1">{t('suggestionSidebar.diekianalysiertdeinenkontext')}</p>
|
<p className="text-xs mt-1">Die KI analysiert deinen Kontext...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -91,7 +89,6 @@ export function SuggestionList() {
|
|||||||
* Uses SuggestionList internally.
|
* Uses SuggestionList internally.
|
||||||
*/
|
*/
|
||||||
export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
|
export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Overlay */}
|
{/* Overlay */}
|
||||||
@@ -111,7 +108,7 @@ export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="font-semibold text-gray-800">{t('suggestionSidebar.kivorschläge')}</h2>
|
<h2 className="font-semibold text-gray-800">KI Vorschläge</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import VideoBlock from './VideoBlock';
|
|||||||
import FileBlock from './FileBlock';
|
import FileBlock from './FileBlock';
|
||||||
import { getBlockComponent } from './registry';
|
import { getBlockComponent } from './registry';
|
||||||
import './registrations'; // plugin-contributed block registrations
|
import './registrations'; // plugin-contributed block registrations
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface BlockRendererProps {
|
interface BlockRendererProps {
|
||||||
blocks: MessageBlock[];
|
blocks: MessageBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
|
const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!blocks || blocks.length === 0) {
|
if (!blocks || blocks.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -57,7 +55,7 @@ const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
|
|||||||
// Fallback for unknown block types
|
// Fallback for unknown block types
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
|
||||||
{t('blockRenderer.unbekannterblocktyp')} {block.block_type}
|
Unbekannter Block-Typ: {block.block_type}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { MessageBlock } from '@/store/commStore';
|
import type { MessageBlock } from '@/store/commStore';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface ContactCardBlockProps {
|
interface ContactCardBlockProps {
|
||||||
block: MessageBlock;
|
block: MessageBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
|
const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { contact_id, name } = block.block_data;
|
const { contact_id, name } = block.block_data;
|
||||||
|
|
||||||
const contactName: string = name || 'Unbekannter Kontakt';
|
const contactName: string = name || 'Unbekannter Kontakt';
|
||||||
@@ -32,7 +30,7 @@ const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p>
|
<p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p>
|
||||||
<p className="text-xs text-secondary-400">{t('contactCardBlock.kontaktanzeigen')}</p>
|
<p className="text-xs text-secondary-400">Kontakt anzeigen</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
<ChevronRight className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import type { MessageBlock } from '@/store/commStore';
|
import type { MessageBlock } from '@/store/commStore';
|
||||||
import { AppWindow, Loader2 } from 'lucide-react';
|
import { AppWindow, Loader2 } from 'lucide-react';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface MiniAppBlockProps {
|
interface MiniAppBlockProps {
|
||||||
block: MessageBlock;
|
block: MessageBlock;
|
||||||
@@ -18,7 +17,6 @@ interface MiniAppDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { app_id, config } = block.block_data;
|
const { app_id, config } = block.block_data;
|
||||||
const [appDef, setAppDef] = useState<MiniAppDef | null>(null);
|
const [appDef, setAppDef] = useState<MiniAppDef | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -90,7 +88,7 @@ const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasConfig && !hasSchema && (
|
{!hasConfig && !hasSchema && (
|
||||||
<p className="text-xs text-secondary-400 text-center py-2">{t('miniAppBlock.keinekonfiguration')}</p>
|
<p className="text-xs text-secondary-400 text-center py-2">Keine Konfiguration</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -217,9 +217,8 @@ function TimelineEntry({
|
|||||||
* Loading skeleton — 3 placeholder entries.
|
* Loading skeleton — 3 placeholder entries.
|
||||||
*/
|
*/
|
||||||
function LoadingSkeleton() {
|
function LoadingSkeleton() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-pulse" aria-label={t('entityHistoryPanel.loadinghistory')}>
|
<div className="space-y-4 animate-pulse" aria-label="Loading history">
|
||||||
{[0, 1, 2].map(i => (
|
{[0, 1, 2].map(i => (
|
||||||
<div key={i} className="flex gap-3">
|
<div key={i} className="flex gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-secondary-200 flex-shrink-0" />
|
<div className="w-8 h-8 rounded-full bg-secondary-200 flex-shrink-0" />
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Printer, FileDown, ChevronDown } from 'lucide-react';
|
import { Printer, FileDown, ChevronDown } from 'lucide-react';
|
||||||
import { printElement, printCurrentPage, exportToPDF } from '@/utils/print';
|
import { printElement, printCurrentPage, exportToPDF } from '@/utils/print';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export interface PrintButtonProps {
|
export interface PrintButtonProps {
|
||||||
/** Element id to print. If omitted, prints the whole page. */
|
/** Element id to print. If omitted, prints the whole page. */
|
||||||
@@ -23,7 +22,6 @@ export function PrintButton({
|
|||||||
filename = 'export',
|
filename = 'export',
|
||||||
className,
|
className,
|
||||||
}: PrintButtonProps) {
|
}: PrintButtonProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -81,8 +79,8 @@ export function PrintButton({
|
|||||||
)}
|
)}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={t('printButton.druckenoderalspdfexportieren')}
|
aria-label="Drucken oder als PDF exportieren"
|
||||||
title={t('printButton.druckenpdf')}
|
title="Drucken / PDF"
|
||||||
data-testid="print-button-trigger"
|
data-testid="print-button-trigger"
|
||||||
>
|
>
|
||||||
<Printer className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
<Printer className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
@@ -130,7 +128,7 @@ export function PrintButton({
|
|||||||
data-testid="print-button-pdf"
|
data-testid="print-button-pdf"
|
||||||
>
|
>
|
||||||
<FileDown className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
<FileDown className="w-4 h-4 text-secondary-500" aria-hidden="true" strokeWidth={2} />
|
||||||
<span>{t('printButton.alspdf')}</span>
|
<span>Als PDF</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export interface SaveFilterDialogProps {
|
|||||||
* Empty / null / undefined values are omitted.
|
* Empty / null / undefined values are omitted.
|
||||||
*/
|
*/
|
||||||
function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
|
function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const entries = Object.entries(criteria).filter(
|
const entries = Object.entries(criteria).filter(
|
||||||
([, v]) => v !== null && v !== undefined && v !== ''
|
([, v]) => v !== null && v !== undefined && v !== ''
|
||||||
);
|
);
|
||||||
@@ -36,7 +35,7 @@ function CriteriaSummary({ criteria }: { criteria: Record<string, any> }) {
|
|||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-secondary-400 italic">
|
<p className="text-sm text-secondary-400 italic">
|
||||||
{t('saveFilterDialog.keineaktivenfilterkriterien')}
|
Keine aktiven Filterkriterien
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,7 +85,6 @@ export function SaveFilterDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
import { useUsers } from '@/api/users';
|
import { useUsers } from '@/api/users';
|
||||||
import { useGroups } from '@/api/groups';
|
import { useGroups } from '@/api/groups';
|
||||||
import type { EntityPermission, PermissionLevel } from '@/api/entityPermissions';
|
import type { EntityPermission, PermissionLevel } from '@/api/entityPermissions';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface ShareDialogProps {
|
interface ShareDialogProps {
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -52,7 +51,6 @@ function permLabel(level: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ShareDialog({ entityType, entityId, entityName, onClose }: ShareDialogProps) {
|
export function ShareDialog({ entityType, entityId, entityName, onClose }: ShareDialogProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: permData, isLoading } = useEntityPermissions(entityType, entityId);
|
const { data: permData, isLoading } = useEntityPermissions(entityType, entityId);
|
||||||
const { data: accessData } = useEntityAccess(entityType, entityId);
|
const { data: accessData } = useEntityAccess(entityType, entityId);
|
||||||
const { data: usersData } = useUsers(1, 100);
|
const { data: usersData } = useUsers(1, 100);
|
||||||
@@ -135,7 +133,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
|
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
|
||||||
aria-label={t('shareDialog.schließen')}
|
aria-label="Schließen"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" strokeWidth={2} />
|
<X className="w-5 h-5" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
@@ -145,9 +143,10 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
{/* Info banner */}
|
{/* Info banner */}
|
||||||
<div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700">
|
<div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700">
|
||||||
<p className="font-medium mb-1">{t('shareDialog.elementteilen')}</p>
|
<p className="font-medium mb-1">Element teilen</p>
|
||||||
<p className="text-primary-600">
|
<p className="text-primary-600">
|
||||||
{t('shareDialog.gewährebenutzernodergruppenzugriffauf')}
|
Gewähre Benutzern oder Gruppen Zugriff auf dieses Element. Die Berechtigungsstufe bestimmt,
|
||||||
|
welche Aktionen durchgeführt werden können.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,7 +168,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
<div className="text-sm text-secondary-400 py-4 text-center">Laden…</div>
|
<div className="text-sm text-secondary-400 py-4 text-center">Laden…</div>
|
||||||
) : permissions.length === 0 ? (
|
) : permissions.length === 0 ? (
|
||||||
<div className="text-sm text-secondary-400 py-4 text-center">
|
<div className="text-sm text-secondary-400 py-4 text-center">
|
||||||
{t('shareDialog.nochkeineberechtigungenvergebendiesesele')}
|
Noch keine Berechtigungen vergeben. Dieses Element ist nur für den Besitzer sichtbar.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -208,7 +207,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
value={perm.expires_at ? perm.expires_at.split('T')[0] : ''}
|
value={perm.expires_at ? perm.expires_at.split('T')[0] : ''}
|
||||||
onChange={(e) => handleUpdateExpiry(perm, e.target.value || '')}
|
onChange={(e) => handleUpdateExpiry(perm, e.target.value || '')}
|
||||||
className="text-xs border border-secondary-200 rounded px-1 py-0.5 bg-transparent focus:outline-none focus:ring-1 focus:ring-primary-500 text-secondary-500"
|
className="text-xs border border-secondary-200 rounded px-1 py-0.5 bg-transparent focus:outline-none focus:ring-1 focus:ring-primary-500 text-secondary-500"
|
||||||
title={t('shareDialog.ablaufdatumsetzen')}
|
title="Ablaufdatum setzen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,8 +229,8 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(perm)}
|
onClick={() => handleDelete(perm)}
|
||||||
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
|
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
|
||||||
title={t('shareDialog.entfernen')}
|
title="Entfernen"
|
||||||
aria-label={t('shareDialog.berechtigungentfernen')}
|
aria-label="Berechtigung entfernen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" strokeWidth={2} />
|
<Trash2 className="w-4 h-4" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
@@ -246,7 +245,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
<div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50">
|
<div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
|
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
|
||||||
<span className="text-sm font-medium text-secondary-700">{t('shareDialog.neueberechtigung')}</span>
|
<span className="text-sm font-medium text-secondary-700">Neue Berechtigung</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type toggle */}
|
{/* Type toggle */}
|
||||||
@@ -331,7 +330,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="flex items-center gap-2 text-sm text-secondary-600 mb-1">
|
<label className="flex items-center gap-2 text-sm text-secondary-600 mb-1">
|
||||||
<Calendar className="w-4 h-4" strokeWidth={2} />
|
<Calendar className="w-4 h-4" strokeWidth={2} />
|
||||||
{t('shareDialog.ablaufdatumoptional')}
|
Ablaufdatum (optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -365,7 +364,7 @@ export function ShareDialog({ entityType, entityId, entityName, onClose }: Share
|
|||||||
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors"
|
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" strokeWidth={2} />
|
<Plus className="w-4 h-4" strokeWidth={2} />
|
||||||
{t('shareDialog.berechtigunghinzufügen')}
|
Berechtigung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
|
|||||||
error={errors.name?.message}
|
error={errors.name?.message}
|
||||||
required
|
required
|
||||||
data-testid="contact-name-input"
|
data-testid="contact-name-input"
|
||||||
placeholder={t('contactEditForm.techcorpgmbh')}
|
placeholder="TechCorp GmbH"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -257,7 +257,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Code */}
|
{/* Code */}
|
||||||
<Input label={t('contacts.code')} {...register('code')} placeholder={t('contactEditForm.k00123')} />
|
<Input label={t('contacts.code')} {...register('code')} placeholder="K-00123" />
|
||||||
|
|
||||||
{/* Communication */}
|
{/* Communication */}
|
||||||
<div className="border border-secondary-200 rounded-lg p-3">
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
@@ -299,7 +299,7 @@ export function ContactEditForm({ onClose, contact, onSaved }: ContactEditFormPr
|
|||||||
<div className="border border-secondary-200 rounded-lg p-3">
|
<div className="border border-secondary-200 rounded-lg p-3">
|
||||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
|
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input label={t('contacts.tags')} {...register('tags')} placeholder={t('contactEditForm.tag1tag2')} />
|
<Input label={t('contacts.tags')} {...register('tags')} placeholder="tag1, tag2" />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ function FolderTreeItem({
|
|||||||
multiSelectedFolders?: string[];
|
multiSelectedFolders?: string[];
|
||||||
onToggleMultiSelect?: (folderId: string) => void;
|
onToggleMultiSelect?: (folderId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const folderKey = `folder:${node.id}` as ContactFilter;
|
const folderKey = `folder:${node.id}` as ContactFilter;
|
||||||
const isActive = selectedFilter === folderKey;
|
const isActive = selectedFilter === folderKey;
|
||||||
@@ -229,8 +228,8 @@ function FolderTreeItem({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onMoreClick(e, node.id); }}
|
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onMoreClick(e, node.id); }}
|
||||||
className="flex-shrink-0 text-secondary-400 hover:text-primary-600 p-1.5 rounded hover:bg-secondary-100 transition-colors touch-manipulation"
|
className="flex-shrink-0 text-secondary-400 hover:text-primary-600 p-1.5 rounded hover:bg-secondary-100 transition-colors touch-manipulation"
|
||||||
title={t('contactFolderTree.optionen')}
|
title="Optionen"
|
||||||
aria-label={t('contactFolderTree.optionen')}
|
aria-label="Optionen"
|
||||||
>
|
>
|
||||||
{icon(ICONS.more, 'w-4 h-4')}
|
{icon(ICONS.more, 'w-4 h-4')}
|
||||||
</button>
|
</button>
|
||||||
@@ -447,8 +446,8 @@ export function ContactFolderTree({
|
|||||||
<button
|
<button
|
||||||
onClick={handleToggleMultiSelectMode}
|
onClick={handleToggleMultiSelectMode}
|
||||||
className={`text-secondary-400 hover:text-primary-600 p-0.5 ${multiSelectMode ? 'text-primary-600 bg-primary-50 rounded' : ''}`}
|
className={`text-secondary-400 hover:text-primary-600 p-0.5 ${multiSelectMode ? 'text-primary-600 bg-primary-50 rounded' : ''}`}
|
||||||
title={t('contactFolderTree.mehrereordnerauswählen')}
|
title="Mehrere Ordner auswählen"
|
||||||
aria-label={t('contactFolderTree.mehrereordnerauswählen')}
|
aria-label="Mehrere Ordner auswählen"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
@@ -462,8 +461,8 @@ export function ContactFolderTree({
|
|||||||
<button
|
<button
|
||||||
onClick={handleNewFolder}
|
onClick={handleNewFolder}
|
||||||
className="text-secondary-400 hover:text-primary-600 p-0.5"
|
className="text-secondary-400 hover:text-primary-600 p-0.5"
|
||||||
title={t('contactFolderTree.neuerordner')}
|
title="Neuer Ordner"
|
||||||
aria-label={t('contactFolderTree.neuerordner')}
|
aria-label="Neuer Ordner"
|
||||||
>
|
>
|
||||||
{icon(ICONS.plus, 'w-3.5 h-3.5')}
|
{icon(ICONS.plus, 'w-3.5 h-3.5')}
|
||||||
</button>
|
</button>
|
||||||
@@ -513,7 +512,7 @@ export function ContactFolderTree({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tree.length === 0 && !foldersLoading && !loading && (
|
{tree.length === 0 && !foldersLoading && !loading && (
|
||||||
<div className="px-2 py-1 text-xs text-secondary-400">{t('contactFolderTree.keineordnervorhanden')}</div>
|
<div className="px-2 py-1 text-xs text-secondary-400">Keine Ordner vorhanden</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -575,7 +574,7 @@ export function ContactFolderTree({
|
|||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-[9998]" onClick={() => setColorPicker(null)} />
|
<div className="fixed inset-0 z-[9998]" onClick={() => setColorPicker(null)} />
|
||||||
<div className="fixed z-[9999] bg-white border border-secondary-200 rounded-lg shadow-lg p-3 min-w-[200px]" style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
<div className="fixed z-[9999] bg-white border border-secondary-200 rounded-lg shadow-lg p-3 min-w-[200px]" style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||||
<div className="text-sm font-semibold text-secondary-700 mb-2">{t('contactFolderTree.farbewählen')}</div>
|
<div className="text-sm font-semibold text-secondary-700 mb-2">Farbe wählen</div>
|
||||||
<div className="grid grid-cols-6 gap-1.5 mb-3">
|
<div className="grid grid-cols-6 gap-1.5 mb-3">
|
||||||
{['#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#64748b', '#475569'].map((c) => (
|
{['#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#64748b', '#475569'].map((c) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -969,7 +969,7 @@ export function ContactList({
|
|||||||
onClick={() => setBulkFolderOpen(!bulkFolderOpen)}
|
onClick={() => setBulkFolderOpen(!bulkFolderOpen)}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||||
>
|
>
|
||||||
{t('contactList.ordnerzuweisen')}
|
Ordner zuweisen ▾
|
||||||
</button>
|
</button>
|
||||||
{bulkFolderOpen && (
|
{bulkFolderOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 max-h-60 overflow-y-auto min-w-[200px] z-30">
|
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 max-h-60 overflow-y-auto min-w-[200px] z-30">
|
||||||
@@ -987,7 +987,7 @@ export function ContactList({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{folderList.length === 0 && (
|
{folderList.length === 0 && (
|
||||||
<div className="px-3 py-2 text-xs text-secondary-400">{t('contactList.keineordner')}</div>
|
<div className="px-3 py-2 text-xs text-secondary-400">Keine Ordner</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -999,7 +999,7 @@ export function ContactList({
|
|||||||
onClick={() => setBulkTagsOpen(!bulkTagsOpen)}
|
onClick={() => setBulkTagsOpen(!bulkTagsOpen)}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||||
>
|
>
|
||||||
{t('contactList.tagshinzufügen')}
|
Tags hinzufügen ▾
|
||||||
</button>
|
</button>
|
||||||
{bulkTagsOpen && (
|
{bulkTagsOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 p-2 z-30">
|
<div className="absolute right-0 top-full mt-1 bg-white text-secondary-800 rounded-lg shadow-lg border border-secondary-200 p-2 z-30">
|
||||||
@@ -1007,7 +1007,7 @@ export function ContactList({
|
|||||||
type="text"
|
type="text"
|
||||||
value={bulkTagsInput}
|
value={bulkTagsInput}
|
||||||
onChange={(e) => setBulkTagsInput(e.target.value)}
|
onChange={(e) => setBulkTagsInput(e.target.value)}
|
||||||
placeholder={t('contactList.tag1tag2')}
|
placeholder="tag1, tag2, ..."
|
||||||
className="w-48 px-2 py-1 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
className="w-48 px-2 py-1 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -1032,7 +1032,7 @@ export function ContactList({
|
|||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-white/20 hover:bg-white/30 rounded transition-colors"
|
||||||
>
|
>
|
||||||
{t('contactList.auswahlaufheben')}
|
Auswahl aufheben
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1041,9 +1041,9 @@ export function ContactList({
|
|||||||
{bulkDeleteConfirm && (
|
{bulkDeleteConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-sm">
|
<div className="bg-white rounded-lg shadow-xl p-6 max-w-sm">
|
||||||
<h3 className="text-lg font-semibold text-secondary-900 mb-2">{t('contactList.löschenbestätigen')}</h3>
|
<h3 className="text-lg font-semibold text-secondary-900 mb-2">Löschen bestätigen</h3>
|
||||||
<p className="text-sm text-secondary-600 mb-4">
|
<p className="text-sm text-secondary-600 mb-4">
|
||||||
{selectedContactIds?.size || 0} {t('contactList.kontaktewirklichlöschen')}
|
{selectedContactIds?.size || 0} Kontakt(e) wirklich löschen?
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -1073,7 +1073,7 @@ export function ContactList({
|
|||||||
{isCustomSortActive && customOrder.length > 0 && (
|
{isCustomSortActive && customOrder.length > 0 && (
|
||||||
<div className="flex items-center gap-1.5 px-3 py-1 bg-amber-50 border-b border-amber-200 text-xs text-amber-700">
|
<div className="flex items-center gap-1.5 px-3 py-1 bg-amber-50 border-b border-amber-200 text-xs text-amber-700">
|
||||||
<Info className="w-3 h-3" />
|
<Info className="w-3 h-3" />
|
||||||
<span>{t('contactList.customsortierungaktivdraganddrop')}</span>
|
<span>Custom Sortierung aktiv — Drag-and-Drop zum Umsortieren</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1138,15 +1138,15 @@ export function ContactList({
|
|||||||
<div ref={colMenuRef} className="relative">
|
<div ref={colMenuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setColMenuOpen(!colMenuOpen)}
|
onClick={() => setColMenuOpen(!colMenuOpen)}
|
||||||
title={t('contactList.spaltenverwalten')}
|
title="Spalten verwalten"
|
||||||
aria-label={t('contactList.spaltenverwalten')}
|
aria-label="Spalten verwalten"
|
||||||
className="p-1 rounded hover:bg-secondary-100 text-secondary-500 hover:text-secondary-700 transition-colors"
|
className="p-1 rounded hover:bg-secondary-100 text-secondary-500 hover:text-secondary-700 transition-colors"
|
||||||
>
|
>
|
||||||
<Settings className="w-3.5 h-3.5" />
|
<Settings className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{colMenuOpen && (
|
{colMenuOpen && (
|
||||||
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-secondary-200 max-h-80 overflow-y-auto min-w-[200px] z-30">
|
<div className="absolute right-0 top-full mt-1 bg-white rounded-lg shadow-lg border border-secondary-200 max-h-80 overflow-y-auto min-w-[200px] z-30">
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">{t('contactList.spaltenverwalten')}</div>
|
<div className="px-3 py-2 text-xs font-semibold text-secondary-700 border-b border-secondary-100">Spalten verwalten</div>
|
||||||
{ALL_COLUMNS.map((col) => (
|
{ALL_COLUMNS.map((col) => (
|
||||||
<label
|
<label
|
||||||
key={col.key}
|
key={col.key}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|||||||
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
|
import { Filter, Plus, X, ChevronDown, Bookmark } from 'lucide-react';
|
||||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||||
import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
import { useCustomFieldDefinitions, type CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// ─── Field definitions ───────────────────────────────────────────────────────
|
// ─── Field definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -246,7 +245,6 @@ function newConditionId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FilterPanel({ filters, onFiltersChange, contactType, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: FilterPanelProps) {
|
export function FilterPanel({ filters, onFiltersChange, contactType, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: FilterPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const btnRef = useRef<HTMLDivElement>(null);
|
const btnRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -375,8 +373,8 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
<div ref={btnRef} className="relative flex-shrink-0">
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
title={t('filterPanel.filter')}
|
title="Filter"
|
||||||
aria-label={t('filterPanel.filter')}
|
aria-label="Filter"
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||||
transition-colors duration-100 cursor-pointer relative
|
transition-colors duration-100 cursor-pointer relative
|
||||||
@@ -417,7 +415,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
{t('filterPanel.allelöschen')}
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -432,7 +430,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
{/* Logic toggle */}
|
{/* Logic toggle */}
|
||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
||||||
<span className="text-xs text-secondary-500">{t('filterPanel.bedingungenverknüpfen')}</span>
|
<span className="text-xs text-secondary-500">Bedingungen verknüpfen:</span>
|
||||||
<button
|
<button
|
||||||
onClick={toggleLogic}
|
onClick={toggleLogic}
|
||||||
className={`
|
className={`
|
||||||
@@ -485,7 +483,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
{/* Saved filters */}
|
{/* Saved filters */}
|
||||||
{savedFilters.length > 0 && (
|
{savedFilters.length > 0 && (
|
||||||
<div className="px-4 py-2 border-b border-secondary-100">
|
<div className="px-4 py-2 border-b border-secondary-100">
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">{t('filterPanel.gespeichertefilter')}</div>
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{savedFilters.map((sf) => (
|
{savedFilters.map((sf) => (
|
||||||
<div key={sf.id} className="flex items-center gap-1 group">
|
<div key={sf.id} className="flex items-center gap-1 group">
|
||||||
@@ -512,7 +510,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
<div className="px-4 py-3 space-y-2">
|
<div className="px-4 py-3 space-y-2">
|
||||||
{filters.conditions.length === 0 && (
|
{filters.conditions.length === 0 && (
|
||||||
<div className="text-center py-6 text-xs text-secondary-400">
|
<div className="text-center py-6 text-xs text-secondary-400">
|
||||||
{t('filterPanel.keinefilteraktivklickeuntenum')}
|
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filters.conditions.map((cond, idx) => {
|
{filters.conditions.map((cond, idx) => {
|
||||||
@@ -572,7 +570,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
||||||
className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
|
className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400"
|
||||||
>
|
>
|
||||||
<option value="">{t('filterPanel.wählen')}</option>
|
<option value="">— wählen —</option>
|
||||||
{def.options?.map((opt) => (
|
{def.options?.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
@@ -589,7 +587,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
type="text"
|
type="text"
|
||||||
value={cond.value}
|
value={cond.value}
|
||||||
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
onChange={(e) => updateCondition(cond.id, { value: e.target.value })}
|
||||||
placeholder={t('filterPanel.wert')}
|
placeholder="Wert…"
|
||||||
className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -617,7 +615,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{t('filterPanel.bedingunghinzufügen')}
|
Bedingung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
{activeCount > 0 && onSaveFilter && (
|
{activeCount > 0 && onSaveFilter && (
|
||||||
<button
|
<button
|
||||||
@@ -625,7 +623,7 @@ export function FilterPanel({ filters, onFiltersChange, contactType, savedFilter
|
|||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<Bookmark className="w-3.5 h-3.5" />
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
{t('filterPanel.filterspeichern')}
|
Filter speichern
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import { useUsers } from '@/api/users';
|
import { useUsers } from '@/api/users';
|
||||||
import { useGroups } from '@/api/groups';
|
import { useGroups } from '@/api/groups';
|
||||||
import type { FolderPermission } from '@/api/contactFolders';
|
import type { FolderPermission } from '@/api/contactFolders';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface FolderPermissionDialogProps {
|
interface FolderPermissionDialogProps {
|
||||||
folderId: string;
|
folderId: string;
|
||||||
@@ -37,7 +36,6 @@ function permLabel(level: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FolderPermissionDialog({ folderId, folderName, onClose }: FolderPermissionDialogProps) {
|
export function FolderPermissionDialog({ folderId, folderName, onClose }: FolderPermissionDialogProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: permData, isLoading } = useFolderPermissions(folderId);
|
const { data: permData, isLoading } = useFolderPermissions(folderId);
|
||||||
const { data: usersData } = useUsers(1, 100);
|
const { data: usersData } = useUsers(1, 100);
|
||||||
const { data: groupsData } = useGroups();
|
const { data: groupsData } = useGroups();
|
||||||
@@ -111,7 +109,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
|
className="text-secondary-400 hover:text-secondary-600 p-1 rounded-md hover:bg-secondary-100"
|
||||||
aria-label={t('folderPermissionDialog.schließen')}
|
aria-label="Schließen"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" strokeWidth={2} />
|
<X className="w-5 h-5" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
@@ -121,9 +119,9 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
|||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
{/* Info banner */}
|
{/* Info banner */}
|
||||||
<div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700">
|
<div className="mb-4 p-3 bg-primary-50 border border-primary-200 rounded-lg text-sm text-primary-700">
|
||||||
<p className="font-medium mb-1">{t('folderPermissionDialog.ordnerteilen')}</p>
|
<p className="font-medium mb-1">Ordner teilen</p>
|
||||||
<p className="text-primary-600">
|
<p className="text-primary-600">
|
||||||
{t('folderPermissionDialog.gewährebenutzernodergruppenzugriffauf')}
|
Gewähre Benutzern oder Gruppen Zugriff auf diesen Ordner. Mit „Vererben" gelten die Rechte auch für alle Unterordner.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,7 +130,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
|||||||
<div className="text-sm text-secondary-400 py-4 text-center">Laden…</div>
|
<div className="text-sm text-secondary-400 py-4 text-center">Laden…</div>
|
||||||
) : permissions.length === 0 ? (
|
) : permissions.length === 0 ? (
|
||||||
<div className="text-sm text-secondary-400 py-4 text-center">
|
<div className="text-sm text-secondary-400 py-4 text-center">
|
||||||
{t('folderPermissionDialog.nochkeineberechtigungenvergebendieserord')}
|
Noch keine Berechtigungen vergeben. Dieser Ordner ist nur für den Besitzer sichtbar.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -178,8 +176,8 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(perm)}
|
onClick={() => handleDelete(perm)}
|
||||||
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
|
className="text-secondary-400 hover:text-red-600 p-1.5 rounded-md hover:bg-red-50 flex-shrink-0"
|
||||||
title={t('folderPermissionDialog.entfernen')}
|
title="Entfernen"
|
||||||
aria-label={t('folderPermissionDialog.berechtigungentfernen')}
|
aria-label="Berechtigung entfernen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" strokeWidth={2} />
|
<Trash2 className="w-4 h-4" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
@@ -194,7 +192,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
|||||||
<div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50">
|
<div className="mt-4 p-4 border-2 border-primary-200 rounded-lg bg-primary-50/50">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
|
<Plus className="w-4 h-4 text-primary-600" strokeWidth={2} />
|
||||||
<span className="text-sm font-medium text-secondary-700">{t('folderPermissionDialog.neueberechtigung')}</span>
|
<span className="text-sm font-medium text-secondary-700">Neue Berechtigung</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type toggle */}
|
{/* Type toggle */}
|
||||||
@@ -266,7 +264,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
|||||||
onChange={(e) => setAddInherit(e.target.checked)}
|
onChange={(e) => setAddInherit(e.target.checked)}
|
||||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
{t('folderPermissionDialog.aufunterordnervererben')}
|
Auf Unterordner vererben
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -292,7 +290,7 @@ export function FolderPermissionDialog({ folderId, folderName, onClose }: Folder
|
|||||||
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors"
|
className="mt-4 w-full flex items-center justify-center gap-2 py-2.5 text-sm text-primary-600 border-2 border-dashed border-primary-200 rounded-lg hover:bg-primary-50 hover:border-primary-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" strokeWidth={2} />
|
<Plus className="w-4 h-4" strokeWidth={2} />
|
||||||
{t('folderPermissionDialog.berechtigunghinzufügen')}
|
Berechtigung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|||||||
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
||||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||||
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// ─── Field definitions ────────────────────────────────────────────────────────
|
// ─── Field definitions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -173,7 +172,6 @@ function newGroupId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) {
|
export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const btnRef = useRef<HTMLDivElement>(null);
|
const btnRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -291,8 +289,8 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
<div ref={btnRef} className="relative flex-shrink-0">
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
title={t('groupPanel.gruppierung')}
|
title="Gruppierung"
|
||||||
aria-label={t('groupPanel.gruppierung')}
|
aria-label="Gruppierung"
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||||
transition-colors duration-100 cursor-pointer relative
|
transition-colors duration-100 cursor-pointer relative
|
||||||
@@ -333,7 +331,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
{t('groupPanel.allelöschen')}
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -349,7 +347,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
{activeCount === 0 && (
|
{activeCount === 0 && (
|
||||||
<div className="px-4 py-3 border-b border-secondary-100">
|
<div className="px-4 py-3 border-b border-secondary-100">
|
||||||
<div className="text-center py-4 text-xs text-secondary-400">
|
<div className="text-center py-4 text-xs text-secondary-400">
|
||||||
{t('groupPanel.keinegruppierungaktivalledatensätzewerde')}
|
Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -437,7 +435,7 @@ export function GroupPanel({ groupState, onGroupChange, contactType }: GroupPane
|
|||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{t('groupPanel.gruppierunghinzufügen')}
|
Gruppierung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Bookmark, Check, Folder, Filter, Group as GroupIcon, ArrowDownAZ, LayoutGrid } from 'lucide-react';
|
import { Bookmark, Check, Folder, Filter, Group as GroupIcon, ArrowDownAZ, LayoutGrid } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export interface SaveViewSelection {
|
export interface SaveViewSelection {
|
||||||
folder: boolean;
|
folder: boolean;
|
||||||
@@ -33,7 +32,6 @@ const defaultSelection: SaveViewSelection = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, hasSort, hasFolder }: SaveViewDialogProps) {
|
export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, hasSort, hasFolder }: SaveViewDialogProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [selection, setSelection] = useState<SaveViewSelection>(defaultSelection);
|
const [selection, setSelection] = useState<SaveViewSelection>(defaultSelection);
|
||||||
|
|
||||||
@@ -68,19 +66,19 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-secondary-100">
|
<div className="flex items-center gap-2 px-5 py-4 border-b border-secondary-100">
|
||||||
<Bookmark className="w-5 h-5 text-primary-600" strokeWidth={2} />
|
<Bookmark className="w-5 h-5 text-primary-600" strokeWidth={2} />
|
||||||
<h2 className="text-base font-semibold text-secondary-800">{t('saveViewDialog.ansichtspeichern')}</h2>
|
<h2 className="text-base font-semibold text-secondary-800">Ansicht speichern</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-5 py-4 space-y-4">
|
<div className="px-5 py-4 space-y-4">
|
||||||
{/* Name input */}
|
{/* Name input */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-secondary-600 mb-1">{t('saveViewDialog.namederansicht')}</label>
|
<label className="block text-xs font-medium text-secondary-600 mb-1">Name der Ansicht</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder={t('saveViewDialog.zbmeinefirmenkontakte')}
|
placeholder="z.B. Meine Firmen-Kontakte"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
|
||||||
className="w-full px-3 py-2 text-sm border border-secondary-200 rounded-md focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300"
|
className="w-full px-3 py-2 text-sm border border-secondary-200 rounded-md focus:outline-none focus:border-primary-400 focus:ring-1 focus:ring-primary-300"
|
||||||
@@ -89,7 +87,7 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
|
|||||||
|
|
||||||
{/* Component selection */}
|
{/* Component selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-secondary-600 mb-2">{t('saveViewDialog.wassollgespeichertwerden')}</label>
|
<label className="block text-xs font-medium text-secondary-600 mb-2">Was soll gespeichert werden?</label>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{options.map((opt) => (
|
{options.map((opt) => (
|
||||||
<label
|
<label
|
||||||
@@ -114,7 +112,7 @@ export function SaveViewDialog({ open, onClose, onSave, hasFilter, hasGroup, has
|
|||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
{!opt.available && (
|
{!opt.available && (
|
||||||
<span className="text-[10px] text-secondary-400 italic">{t('saveViewDialog.nichtaktiv')}</span>
|
<span className="text-[10px] text-secondary-400 italic">nicht aktiv</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|||||||
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
|
import { ArrowDownAZ, ArrowUpZA, Plus, X } from 'lucide-react';
|
||||||
import type { UnifiedContact } from '@/api/unifiedContacts';
|
import type { UnifiedContact } from '@/api/unifiedContacts';
|
||||||
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
import { useCustomFieldDefinitions } from '@/api/customFieldDefinitions';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
|
// ─── Field definitions (reuse from FilterPanel) ────────────────────────────────
|
||||||
|
|
||||||
@@ -165,7 +164,6 @@ function newSortId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SortPanel({ sortState, onSortChange, contactType }: SortPanelProps) {
|
export function SortPanel({ sortState, onSortChange, contactType }: SortPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const btnRef = useRef<HTMLDivElement>(null);
|
const btnRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -284,8 +282,8 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
<div ref={btnRef} className="relative flex-shrink-0">
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
title={t('sortPanel.sortieren')}
|
title="Sortieren"
|
||||||
aria-label={t('sortPanel.sortieren')}
|
aria-label="Sortieren"
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||||
transition-colors duration-100 cursor-pointer relative
|
transition-colors duration-100 cursor-pointer relative
|
||||||
@@ -326,7 +324,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
className="text-xs text-secondary-500 hover:text-red-600 transition-colors"
|
||||||
>
|
>
|
||||||
{t('sortPanel.allelöschen')}
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -342,7 +340,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
{activeCount === 0 && (
|
{activeCount === 0 && (
|
||||||
<div className="px-4 py-3 border-b border-secondary-100">
|
<div className="px-4 py-3 border-b border-secondary-100">
|
||||||
<div className="text-center py-4 text-xs text-secondary-400">
|
<div className="text-center py-4 text-xs text-secondary-400">
|
||||||
{t('sortPanel.keinesortierungaktivdatensätzekönnenper')}
|
Keine Sortierung aktiv. Datensätze können per Drag-and-Drop umsortiert werden.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -450,7 +448,7 @@ export function SortPanel({ sortState, onSortChange, contactType }: SortPanelPro
|
|||||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{t('sortPanel.sortierunghinzufügen')}
|
Sortierung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Select } from '@/components/ui/Select';
|
|||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import type { CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
import type { CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export interface CustomFieldRendererProps {
|
export interface CustomFieldRendererProps {
|
||||||
definition: CustomFieldDefinition;
|
definition: CustomFieldDefinition;
|
||||||
@@ -18,7 +17,6 @@ export interface CustomFieldRendererProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) {
|
export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const generatedId = useId();
|
const generatedId = useId();
|
||||||
const fieldId = `cf-${definition.id || generatedId}`;
|
const fieldId = `cf-${definition.id || generatedId}`;
|
||||||
const { field_type, options, required } = definition;
|
const { field_type, options, required } = definition;
|
||||||
@@ -41,7 +39,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
|||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{definition.label}
|
{definition.label}
|
||||||
{required && <span className="text-danger-500 ml-1" aria-label={t('customFieldRenderer.required')}>*</span>}
|
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +73,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
{definition.label}
|
{definition.label}
|
||||||
{required && <span className="text-danger-500 ml-1" aria-label={t('customFieldRenderer.required')}>*</span>}
|
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{selectedValues.length > 0 && (
|
{selectedValues.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||||
@@ -108,9 +106,9 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : availableOptions.length === 0 ? (
|
) : availableOptions.length === 0 ? (
|
||||||
<p className="text-sm text-secondary-400">{t('customFieldRenderer.keineoptionenverfügbar')}</p>
|
<p className="text-sm text-secondary-400">Keine Optionen verfügbar</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-secondary-400">{t('customFieldRenderer.alleoptionenausgewählt')}</p>
|
<p className="text-sm text-secondary-400">Alle Optionen ausgewählt</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -125,7 +123,7 @@ export function CustomFieldRenderer({ definition, value, onChange }: CustomField
|
|||||||
label={definition.label}
|
label={definition.label}
|
||||||
required={required}
|
required={required}
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
placeholder={t('customFieldRenderer.bittewählen')}
|
placeholder="— Bitte wählen —"
|
||||||
value={value ?? ''}
|
value={value ?? ''}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,11 +44,10 @@ interface TabDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChatPanel() {
|
function ChatPanel() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" data-testid="chat-panel">
|
<div className="flex flex-col h-full" data-testid="chat-panel">
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
<p className="text-sm text-secondary-400 text-center py-8">{t('aISidebar.chatsidebarcomingsoon')}</p>
|
<p className="text-sm text-secondary-400 text-center py-8">Chat-Sidebar - Coming Soon</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -104,7 +103,6 @@ export function AISidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
if (aiSidebarTab === 'proactive') {
|
if (aiSidebarTab === 'proactive') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -119,14 +117,14 @@ export function AISidebar() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto p-3 gap-2" data-testid="notification-list">
|
<div className="flex flex-col h-full overflow-y-auto p-3 gap-2" data-testid="notification-list">
|
||||||
{notifications.length === 0 && (
|
{notifications.length === 0 && (
|
||||||
<p className="text-sm text-secondary-400 text-center py-4">{t('aISidebar.keinebenachrichtigungen')}</p>
|
<p className="text-sm text-secondary-400 text-center py-4">Keine Benachrichtigungen</p>
|
||||||
)}
|
)}
|
||||||
{notifications.map((msg, i) => (
|
{notifications.map((msg, i) => (
|
||||||
<div key={i} className="flex items-start justify-between gap-2 px-3 py-2 rounded-lg border border-secondary-200 bg-secondary-50 hover:border-secondary-300 hover:bg-secondary-100 transition-colors text-sm text-secondary-700 group">
|
<div key={i} className="flex items-start justify-between gap-2 px-3 py-2 rounded-lg border border-secondary-200 bg-secondary-50 hover:border-secondary-300 hover:bg-secondary-100 transition-colors text-sm text-secondary-700 group">
|
||||||
<span className="flex-1 leading-snug">{msg}</span>
|
<span className="flex-1 leading-snug">{msg}</span>
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded text-secondary-300 hover:text-danger-600 hover:bg-danger-50 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5"
|
className="p-1 rounded text-secondary-300 hover:text-danger-600 hover:bg-danger-50 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5"
|
||||||
aria-label={t('aISidebar.benachrichtigunglöschen')}
|
aria-label="Benachrichtigung löschen"
|
||||||
onClick={() => removeNotification(i)}
|
onClick={() => removeNotification(i)}
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
|
<X className="w-3.5 h-3.5" aria-hidden="true" strokeWidth={2} />
|
||||||
@@ -152,13 +150,13 @@ export function AISidebar() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-center px-4 gap-3">
|
<div className="flex flex-col items-center justify-center h-full text-center px-4 gap-3">
|
||||||
<p className="text-sm text-secondary-500">
|
<p className="text-sm text-secondary-500">
|
||||||
{t('aISidebar.dervollekichatistauf')}
|
Der volle KI-Chat ist auf der AI-Assistant-Seite verfügbar.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/ai-assistant"
|
to="/ai-assistant"
|
||||||
className="inline-flex items-center px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch"
|
className="inline-flex items-center px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch"
|
||||||
>
|
>
|
||||||
{t('aISidebar.aiassistantöffnen')}
|
AI Assistant öffnen
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -192,8 +190,8 @@ export function AISidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleAISidebar}
|
onClick={toggleAISidebar}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
||||||
title={t('aISidebar.einklappen')}
|
title="Einklappen"
|
||||||
aria-label={t('aISidebar.kiassistenteinklappen')}
|
aria-label="KI Assistent einklappen"
|
||||||
>
|
>
|
||||||
{chevronRightIcon}
|
{chevronRightIcon}
|
||||||
</button>
|
</button>
|
||||||
@@ -212,7 +210,7 @@ export function AISidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleAISidebar}
|
onClick={toggleAISidebar}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
aria-label={t('aISidebar.zurück')}
|
aria-label="Zurück"
|
||||||
data-testid="ai-sidebar-back"
|
data-testid="ai-sidebar-back"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ function MessageFeed({
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,7 +147,7 @@ function MessageFeed({
|
|||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-secondary-400">
|
<div className="flex items-center justify-center h-full text-sm text-secondary-400">
|
||||||
{t('messageSidebar.keinenachrichten')}
|
Keine Nachrichten
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -206,7 +205,6 @@ function MessageInput({
|
|||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
@@ -238,7 +236,7 @@ function MessageInput({
|
|||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={disabled || !text.trim()}
|
disabled={disabled || !text.trim()}
|
||||||
className="p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:bg-secondary-200 disabled:text-secondary-400 transition-colors min-h-touch min-w-touch flex items-center justify-center"
|
className="p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:bg-secondary-200 disabled:text-secondary-400 transition-colors min-h-touch min-w-touch flex items-center justify-center"
|
||||||
aria-label={t('messageSidebar.senden')}
|
aria-label="Senden"
|
||||||
data-testid="message-send-btn"
|
data-testid="message-send-btn"
|
||||||
>
|
>
|
||||||
{sendIcon}
|
{sendIcon}
|
||||||
@@ -388,7 +386,6 @@ export function MessageSidebar() {
|
|||||||
|
|
||||||
// Determine which pinned conv to select when a quick-access button is clicked
|
// Determine which pinned conv to select when a quick-access button is clicked
|
||||||
const handleQuickAccess = (qa: QuickAccessDef) => {
|
const handleQuickAccess = (qa: QuickAccessDef) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
setCurrentView(qa.view);
|
setCurrentView(qa.view);
|
||||||
if (qa.view === 'conversations' && qa.filterPinned) {
|
if (qa.view === 'conversations' && qa.filterPinned) {
|
||||||
// Find pinned conversation matching the filter by title
|
// Find pinned conversation matching the filter by title
|
||||||
@@ -483,8 +480,8 @@ export function MessageSidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleMessageSidebar}
|
onClick={toggleMessageSidebar}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
||||||
title={t('messageSidebar.einklappen')}
|
title="Einklappen"
|
||||||
aria-label={t('messageSidebar.messagingeinklappen')}
|
aria-label="Messaging einklappen"
|
||||||
>
|
>
|
||||||
{chevronRightIcon}
|
{chevronRightIcon}
|
||||||
</button>
|
</button>
|
||||||
@@ -501,7 +498,7 @@ export function MessageSidebar() {
|
|||||||
<p className="text-sm text-red-500 text-center py-2">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>
|
<p className="text-sm text-red-500 text-center py-2">{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}</p>
|
||||||
)}
|
)}
|
||||||
{!loading && conversations.length === 0 && !error && (
|
{!loading && conversations.length === 0 && !error && (
|
||||||
<p className="text-sm text-secondary-400 text-center py-4">{t('messageSidebar.keinekonversationen')}</p>
|
<p className="text-sm text-secondary-400 text-center py-4">Keine Konversationen</p>
|
||||||
)}
|
)}
|
||||||
{/* Pinned conversations */}
|
{/* Pinned conversations */}
|
||||||
{pinnedConversations.length > 0 && (
|
{pinnedConversations.length > 0 && (
|
||||||
@@ -540,7 +537,6 @@ export function MessageSidebar() {
|
|||||||
|
|
||||||
// ─── Main Content Area ───
|
// ─── Main Content Area ───
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
if (currentView === 'team') {
|
if (currentView === 'team') {
|
||||||
return <TeamPanel onStartDirectChat={handleStartDirectChat} />;
|
return <TeamPanel onStartDirectChat={handleStartDirectChat} />;
|
||||||
}
|
}
|
||||||
@@ -568,7 +564,7 @@ export function MessageSidebar() {
|
|||||||
)}
|
)}
|
||||||
{isSystemLocked && (
|
{isSystemLocked && (
|
||||||
<div className="p-3 border-t border-secondary-200 text-center">
|
<div className="p-3 border-t border-secondary-200 text-center">
|
||||||
<span className="text-xs text-secondary-400">{t('messageSidebar.systemkonversationschreibgeschützt')}</span>
|
<span className="text-xs text-secondary-400">System-Konversation (schreibgeschützt)</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -588,7 +584,7 @@ export function MessageSidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleMessageSidebar}
|
onClick={toggleMessageSidebar}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
aria-label={t('messageSidebar.zurück')}
|
aria-label="Zurück"
|
||||||
data-testid="message-sidebar-back"
|
data-testid="message-sidebar-back"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
<ChevronLeft className="w-5 h-5" aria-hidden="true" strokeWidth={2} />
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore, type ToolbarItem } from '@/store/pluginToolbarStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
function ToolbarButton({ item }: { item: ToolbarItem }) {
|
function ToolbarButton({ item }: { item: ToolbarItem }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={item.onClick}
|
onClick={item.onClick}
|
||||||
@@ -23,7 +21,6 @@ function ToolbarButton({ item }: { item: ToolbarItem }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarSearch({ item }: { item: ToolbarItem }) {
|
function ToolbarSearch({ item }: { item: ToolbarItem }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
const [value, setValue] = React.useState('');
|
const [value, setValue] = React.useState('');
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
@@ -77,7 +74,7 @@ function ToolbarSearch({ item }: { item: ToolbarItem }) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
className="ml-1 p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600 transition-colors"
|
className="ml-1 p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600 transition-colors"
|
||||||
aria-label={t('pluginToolbar.sucheschließen')}
|
aria-label="Suche schließen"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
|||||||
@@ -182,21 +182,21 @@ export function Sidebar() {
|
|||||||
'flex flex-col transition-transform motion-safe:duration-300',
|
'flex flex-col transition-transform motion-safe:duration-300',
|
||||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:hidden'
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full md:hidden'
|
||||||
)}
|
)}
|
||||||
aria-label={t('sidebar.seitenleistenavigation')}
|
aria-label="Seitenleiste Navigation"
|
||||||
data-testid="sidebar"
|
data-testid="sidebar"
|
||||||
>
|
>
|
||||||
<div className="h-[58px] flex items-center gap-2 px-4 border-b border-secondary-700">
|
<div className="h-[58px] flex items-center gap-2 px-4 border-b border-secondary-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/start')}
|
onClick={() => navigate('/start')}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-700 hover:text-white flex items-center justify-center focus:outline-none"
|
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-700 hover:text-white flex items-center justify-center focus:outline-none"
|
||||||
aria-label={t('sidebar.zurückzurstartseite')}
|
aria-label="Zurück zur Startseite"
|
||||||
title={t('sidebar.zurückzurstartseite')}
|
title="Zurück zur Startseite"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xl font-bold text-white">leocrm</span>
|
<span className="text-xl font-bold text-white">leocrm</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto py-4" aria-label={t('sidebar.hauptnavigation')}>
|
<nav className="flex-1 overflow-y-auto py-4" aria-label="Hauptnavigation">
|
||||||
<ul className="space-y-1 px-2">
|
<ul className="space-y-1 px-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const groups = new Map<string, typeof allMenuItems>();
|
const groups = new Map<string, typeof allMenuItems>();
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons
|
// Curated icon map — avoids `import * as LucideIcons` which loads ALL icons
|
||||||
// and causes OOM in tests (ARCH-063).
|
// and causes OOM in tests (ARCH-063).
|
||||||
@@ -70,7 +69,6 @@ function getIcon(name: string): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemProps) {
|
export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -101,7 +99,7 @@ export function SortableMenuItem({ id, label, icon, isGroup }: SortableMenuItemP
|
|||||||
className="cursor-grab active:cursor-grabbing text-secondary-400 hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
|
className="cursor-grab active:cursor-grabbing text-secondary-400 hover:text-secondary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
aria-label={t('sortableMenuItem.ziehenzumsortieren')}
|
aria-label="Ziehen zum Sortieren"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4" />
|
<GripVertical className="w-4 h-4" />
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function TopBar() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
aria-label={t('topBar.seitenleisteeinausklappen')}
|
aria-label="Seitenleiste ein-/ausklappen"
|
||||||
aria-expanded={true}
|
aria-expanded={true}
|
||||||
>
|
>
|
||||||
<Menu className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
<Menu className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export function ComposeModal({
|
|||||||
label={t('mail.to')}
|
label={t('mail.to')}
|
||||||
{...register('to')}
|
{...register('to')}
|
||||||
error={errorMsg(errors.to?.message)}
|
error={errorMsg(errors.to?.message)}
|
||||||
placeholder={t('composeModal.recipientexamplecom')}
|
placeholder="recipient@example.com"
|
||||||
required
|
required
|
||||||
data-testid="compose-to"
|
data-testid="compose-to"
|
||||||
/>
|
/>
|
||||||
@@ -322,13 +322,13 @@ export function ComposeModal({
|
|||||||
label={t('mail.cc')}
|
label={t('mail.cc')}
|
||||||
{...register('cc')}
|
{...register('cc')}
|
||||||
error={errorMsg(errors.cc?.message)}
|
error={errorMsg(errors.cc?.message)}
|
||||||
placeholder={t('composeModal.ccexamplecom')}
|
placeholder="cc@example.com"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.bcc')}
|
label={t('mail.bcc')}
|
||||||
{...register('bcc')}
|
{...register('bcc')}
|
||||||
error={errorMsg(errors.bcc?.message)}
|
error={errorMsg(errors.bcc?.message)}
|
||||||
placeholder={t('composeModal.bccexamplecom')}
|
placeholder="bcc@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -376,7 +376,7 @@ export function ComposeModal({
|
|||||||
>
|
>
|
||||||
{t('mail.addAttachment')}
|
{t('mail.addAttachment')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-secondary-400">{t('composeModal.max25mbperfile')}</span>
|
<span className="text-xs text-secondary-400">Max 25 MB per file</span>
|
||||||
</div>
|
</div>
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="mt-2 space-y-1">
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export function MailComposeForm({
|
|||||||
label={t('mail.to')}
|
label={t('mail.to')}
|
||||||
{...register('to')}
|
{...register('to')}
|
||||||
error={errorMsg(errors.to?.message)}
|
error={errorMsg(errors.to?.message)}
|
||||||
placeholder={t('mailComposeForm.recipientexamplecom')}
|
placeholder="recipient@example.com"
|
||||||
required
|
required
|
||||||
data-testid="compose-to"
|
data-testid="compose-to"
|
||||||
/>
|
/>
|
||||||
@@ -323,13 +323,13 @@ export function MailComposeForm({
|
|||||||
label={t('mail.cc')}
|
label={t('mail.cc')}
|
||||||
{...register('cc')}
|
{...register('cc')}
|
||||||
error={errorMsg(errors.cc?.message)}
|
error={errorMsg(errors.cc?.message)}
|
||||||
placeholder={t('mailComposeForm.ccexamplecom')}
|
placeholder="cc@example.com"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.bcc')}
|
label={t('mail.bcc')}
|
||||||
{...register('bcc')}
|
{...register('bcc')}
|
||||||
error={errorMsg(errors.bcc?.message)}
|
error={errorMsg(errors.bcc?.message)}
|
||||||
placeholder={t('mailComposeForm.bccexamplecom')}
|
placeholder="bcc@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -377,7 +377,7 @@ export function MailComposeForm({
|
|||||||
>
|
>
|
||||||
{t('mail.addAttachment')}
|
{t('mail.addAttachment')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-secondary-400">{t('mailComposeForm.max25mbperfile')}</span>
|
<span className="text-xs text-secondary-400">Max 25 MB per file</span>
|
||||||
</div>
|
</div>
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="mt-2 space-y-1">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Filter, Plus, X, Bookmark } from 'lucide-react';
|
import { Filter, Plus, X, Bookmark } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// ─── Field definitions ───────────────────────────────────────────────────────
|
// ─── Field definitions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -184,7 +183,6 @@ function newConditionId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: MailFilterPanelProps) {
|
export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], onSaveFilter, onLoadFilter, onDeleteFilter }: MailFilterPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const btnRef = useRef<HTMLDivElement>(null);
|
const btnRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -274,8 +272,8 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
|||||||
<div ref={btnRef} className="relative flex-shrink-0">
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
title={t('mailFilterPanel.filter')}
|
title="Filter"
|
||||||
aria-label={t('mailFilterPanel.filter')}
|
aria-label="Filter"
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||||
transition-colors duration-100 cursor-pointer relative
|
transition-colors duration-100 cursor-pointer relative
|
||||||
@@ -313,7 +311,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">
|
<button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">
|
||||||
{t('mailFilterPanel.allelöschen')}
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600">
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600">
|
||||||
@@ -325,7 +323,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
|||||||
{/* Logic toggle */}
|
{/* Logic toggle */}
|
||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-100">
|
||||||
<span className="text-xs text-secondary-500">{t('mailFilterPanel.bedingungenverknüpfen')}</span>
|
<span className="text-xs text-secondary-500">Bedingungen verknüpfen:</span>
|
||||||
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'AND' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>UND</button>
|
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'AND' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>UND</button>
|
||||||
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'OR' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>ODER</button>
|
<button onClick={toggleLogic} className={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${filters.logic === 'OR' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'}`}>ODER</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +348,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
|||||||
{/* Saved filters */}
|
{/* Saved filters */}
|
||||||
{(savedFilters?.length ?? 0) > 0 && (
|
{(savedFilters?.length ?? 0) > 0 && (
|
||||||
<div className="px-4 py-2 border-b border-secondary-100">
|
<div className="px-4 py-2 border-b border-secondary-100">
|
||||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">{t('mailFilterPanel.gespeichertefilter')}</div>
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-secondary-400 mb-1.5">Gespeicherte Filter</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{savedFilters.map((sf) => (
|
{savedFilters.map((sf) => (
|
||||||
<div key={sf.id} className="flex items-center gap-1 group">
|
<div key={sf.id} className="flex items-center gap-1 group">
|
||||||
@@ -371,7 +369,7 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
|||||||
<div className="px-4 py-3 space-y-2">
|
<div className="px-4 py-3 space-y-2">
|
||||||
{(filters.conditions?.length ?? 0) === 0 && (
|
{(filters.conditions?.length ?? 0) === 0 && (
|
||||||
<div className="text-center py-6 text-xs text-secondary-400">
|
<div className="text-center py-6 text-xs text-secondary-400">
|
||||||
{t('mailFilterPanel.keinefilteraktivklickeuntenum')}
|
Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filters.conditions.map((cond, idx) => {
|
{filters.conditions.map((cond, idx) => {
|
||||||
@@ -411,13 +409,13 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
|||||||
{currentOp?.needsValue ? (
|
{currentOp?.needsValue ? (
|
||||||
def?.type === 'select' ? (
|
def?.type === 'select' ? (
|
||||||
<select value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400">
|
<select value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded bg-white cursor-pointer focus:outline-none focus:border-primary-400">
|
||||||
<option value="">{t('mailFilterPanel.wählen')}</option>
|
<option value="">— wählen —</option>
|
||||||
{def.options?.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
|
{def.options?.map((opt) => (<option key={opt.value} value={opt.value}>{opt.label}</option>))}
|
||||||
</select>
|
</select>
|
||||||
) : def?.type === 'date' ? (
|
) : def?.type === 'date' ? (
|
||||||
<input type="date" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" />
|
<input type="date" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} className="px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" />
|
||||||
) : (
|
) : (
|
||||||
<input type="text" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} placeholder={t('mailFilterPanel.wert')} className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" />
|
<input type="text" value={cond.value} onChange={(e) => updateCondition(cond.id, { value: e.target.value })} placeholder="Wert…" className="w-24 px-2 py-1.5 text-xs border border-secondary-200 rounded focus:outline-none focus:border-primary-400" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="w-24" />
|
<div className="w-24" />
|
||||||
@@ -436,12 +434,12 @@ export function MailFilterPanel({ filters, onFiltersChange, savedFilters = [], o
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
|
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{t('mailFilterPanel.bedingunghinzufügen')}
|
Bedingung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
{activeCount > 0 && onSaveFilter && (
|
{activeCount > 0 && onSaveFilter && (
|
||||||
<button onClick={handleSaveFilter} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
|
<button onClick={handleSaveFilter} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors">
|
||||||
<Bookmark className="w-3.5 h-3.5" />
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
{t('mailFilterPanel.filterspeichern')}
|
Filter speichern
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ function ContextMenu({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEmptyFolder: (folderId: string, folderName: string) => void;
|
onEmptyFolder: (folderId: string, folderName: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -137,7 +136,7 @@ function ContextMenu({
|
|||||||
onClick={() => { onEmptyFolder(state.folderId, state.folderName); onClose(); }}
|
onClick={() => { onEmptyFolder(state.folderId, state.folderName); onClose(); }}
|
||||||
className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
|
className="w-full text-left px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
{t('mailFolderTree.ordnerleeren')}
|
Ordner leeren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
import { Group as GroupIcon, Plus, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
type FieldType = 'text' | 'number' | 'date';
|
type FieldType = 'text' | 'number' | 'date';
|
||||||
|
|
||||||
@@ -115,7 +114,6 @@ interface MailGroupPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProps) {
|
export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const btnRef = useRef<HTMLDivElement>(null);
|
const btnRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -170,8 +168,8 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
|
|||||||
<div ref={btnRef} className="relative flex-shrink-0">
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
title={t('mailGroupPanel.gruppierung')}
|
title="Gruppierung"
|
||||||
aria-label={t('mailGroupPanel.gruppierung')}
|
aria-label="Gruppierung"
|
||||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
||||||
>
|
>
|
||||||
<GroupIcon className="w-3.5 h-3.5" strokeWidth={2} />
|
<GroupIcon className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
@@ -186,12 +184,12 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
|
|||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
||||||
<span className="text-sm font-semibold text-secondary-800">Gruppierung</span>
|
<span className="text-sm font-semibold text-secondary-800">Gruppierung</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">{t('mailGroupPanel.allelöschen')}</button>}
|
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle löschen</button>}
|
||||||
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 space-y-2">
|
<div className="px-4 py-3 space-y-2">
|
||||||
{groupState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">{t('mailGroupPanel.keinegruppierungaktivklickeuntenum')}</div>}
|
{groupState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">Keine Gruppierung aktiv. Klicke unten um ein Feld hinzuzufügen.</div>}
|
||||||
{groupState.conditions.map((cond, idx) => (
|
{groupState.conditions.map((cond, idx) => (
|
||||||
<div key={cond.id} className="flex items-center gap-1.5">
|
<div key={cond.id} className="flex items-center gap-1.5">
|
||||||
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
|
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
|
||||||
@@ -205,7 +203,7 @@ export function MailGroupPanel({ groupState, onGroupChange }: MailGroupPanelProp
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
||||||
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />{t('mailGroupPanel.feldhinzufügen')}</button>
|
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />Feld hinzufügen</button>
|
||||||
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
|
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { ArrowUpDown, Plus, X, ChevronUp, ChevronDown } from 'lucide-react';
|
import { ArrowUpDown, Plus, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
type FieldType = 'text' | 'number' | 'date';
|
type FieldType = 'text' | 'number' | 'date';
|
||||||
|
|
||||||
@@ -87,7 +86,6 @@ interface MailSortPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const btnRef = useRef<HTMLDivElement>(null);
|
const btnRef = useRef<HTMLDivElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -146,8 +144,8 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
|||||||
<div ref={btnRef} className="relative flex-shrink-0">
|
<div ref={btnRef} className="relative flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
title={t('mailSortPanel.sortieren')}
|
title="Sortieren"
|
||||||
aria-label={t('mailSortPanel.sortieren')}
|
aria-label="Sortieren"
|
||||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors duration-100 cursor-pointer relative ${open || activeCount > 0 ? 'bg-primary-100 text-primary-700' : 'text-secondary-600 hover:bg-secondary-100 hover:text-secondary-900'}`}
|
||||||
>
|
>
|
||||||
<ArrowUpDown className="w-3.5 h-3.5" strokeWidth={2} />
|
<ArrowUpDown className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
@@ -162,12 +160,12 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
|||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-100">
|
||||||
<span className="text-sm font-semibold text-secondary-800">Sortieren</span>
|
<span className="text-sm font-semibold text-secondary-800">Sortieren</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">{t('mailSortPanel.allelöschen')}</button>}
|
{activeCount > 0 && <button onClick={clearAll} className="text-xs text-secondary-500 hover:text-red-600 transition-colors">Alle löschen</button>}
|
||||||
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
|
<button onClick={() => setOpen(false)} className="p-1 rounded hover:bg-secondary-100 text-secondary-400 hover:text-secondary-600"><X className="w-3.5 h-3.5" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 space-y-2">
|
<div className="px-4 py-3 space-y-2">
|
||||||
{sortState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">{t('mailSortPanel.keinesortierungaktivklickeuntenum')}</div>}
|
{sortState.conditions.length === 0 && <div className="text-center py-6 text-xs text-secondary-400">Keine Sortierung aktiv. Klicke unten um ein Feld hinzuzufügen.</div>}
|
||||||
{sortState.conditions.map((cond, idx) => (
|
{sortState.conditions.map((cond, idx) => (
|
||||||
<div key={cond.id} className="flex items-center gap-1.5">
|
<div key={cond.id} className="flex items-center gap-1.5">
|
||||||
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
|
<span className="text-[10px] font-bold text-secondary-400 w-6 text-center">{idx + 1}.</span>
|
||||||
@@ -184,7 +182,7 @@ export function MailSortPanel({ sortState, onSortChange }: MailSortPanelProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-100">
|
||||||
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />{t('mailSortPanel.feldhinzufügen')}</button>
|
<button onClick={addCondition} className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 rounded transition-colors"><Plus className="w-3.5 h-3.5" />Feld hinzufügen</button>
|
||||||
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
|
<button onClick={() => setOpen(false)} className="px-3 py-1 text-xs font-medium text-white bg-primary-600 hover:bg-primary-700 rounded transition-colors">Fertig</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function PgpSettings() {
|
|||||||
value={privateKey}
|
value={privateKey}
|
||||||
onChange={(e) => setPrivateKey(e.target.value)}
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
className="w-full min-h-32 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full min-h-32 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('pgpSettings.beginpgpprivatekeyblock')}
|
placeholder="-----BEGIN PGP PRIVATE KEY BLOCK-----"
|
||||||
data-testid="pgp-private-key"
|
data-testid="pgp-private-key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +135,7 @@ export function PgpSettings() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-secondary-900">{key.user_id}</p>
|
<p className="text-sm font-medium text-secondary-900">{key.user_id}</p>
|
||||||
<p className="text-xs text-secondary-500">{t('pgpSettings.keyid')} {key.key_id}</p>
|
<p className="text-xs text-secondary-500">Key ID: {key.key_id}</p>
|
||||||
<p className="text-xs text-secondary-400">Fingerprint: {key.fingerprint}</p>
|
<p className="text-xs text-secondary-400">Fingerprint: {key.fingerprint}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-secondary-400">{key.is_private ? t('mail.privateKeyLabel') : t('mail.publicKey')}</span>
|
<span className="text-xs text-secondary-400">{key.is_private ? t('mail.privateKeyLabel') : t('mail.publicKey')}</span>
|
||||||
@@ -152,7 +152,7 @@ export function PgpSettings() {
|
|||||||
label={t('mail.contactId')}
|
label={t('mail.contactId')}
|
||||||
value={contactId}
|
value={contactId}
|
||||||
onChange={(e) => setContactId(e.target.value)}
|
onChange={(e) => setContactId(e.target.value)}
|
||||||
placeholder={t('pgpSettings.contactuuid')}
|
placeholder="contact-uuid"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.publicKey')}</label>
|
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.publicKey')}</label>
|
||||||
@@ -160,7 +160,7 @@ export function PgpSettings() {
|
|||||||
value={contactPublicKey}
|
value={contactPublicKey}
|
||||||
onChange={(e) => setContactPublicKey(e.target.value)}
|
onChange={(e) => setContactPublicKey(e.target.value)}
|
||||||
className="w-full min-h-24 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full min-h-24 border border-secondary-300 rounded-md p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('pgpSettings.beginpgppublickeyblock')}
|
placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
||||||
data-testid="contact-public-key"
|
data-testid="contact-public-key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +175,7 @@ export function PgpSettings() {
|
|||||||
{contactKeys.map((ck) => (
|
{contactKeys.map((ck) => (
|
||||||
<li key={ck.contact_id} className="p-3 rounded-md border border-secondary-200">
|
<li key={ck.contact_id} className="p-3 rounded-md border border-secondary-200">
|
||||||
<p className="text-sm font-medium text-secondary-900">{ck.contact_name}</p>
|
<p className="text-sm font-medium text-secondary-900">{ck.contact_name}</p>
|
||||||
<p className="text-xs text-secondary-500">{t('pgpSettings.keyid')} {ck.key_id}</p>
|
<p className="text-xs text-secondary-500">Key ID: {ck.key_id}</p>
|
||||||
<p className="text-xs text-secondary-400">Fingerprint: {ck.fingerprint}</p>
|
<p className="text-xs text-secondary-400">Fingerprint: {ck.fingerprint}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -195,13 +195,13 @@ export function RichTextEditor({ content, onChange, placeholder, editable = true
|
|||||||
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
||||||
|
|
||||||
{/* Color */}
|
{/* Color */}
|
||||||
<label className="p-1.5 rounded hover:bg-secondary-100 cursor-pointer" title={t('richTextEditor.textcolor')}>
|
<label className="p-1.5 rounded hover:bg-secondary-100 cursor-pointer" title="Text color">
|
||||||
<Heading className="w-4 h-4" strokeWidth={2} />
|
<Heading className="w-4 h-4" strokeWidth={2} />
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
|
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
|
||||||
title={t('richTextEditor.textcolor')}
|
title="Text color"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function SignatureManager() {
|
|||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
content={bodyHtmlValue}
|
content={bodyHtmlValue}
|
||||||
onChange={(html: string) => setSigValue('body_html', html)}
|
onChange={(html: string) => setSigValue('body_html', html)}
|
||||||
placeholder={t('signatureManager.pmitfreundlichengrüßenbruser')}
|
placeholder="<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
<label className="flex items-center gap-2 text-sm text-secondary-700">
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NotificationItem as NotificationItemType } from '@/api/notifications';
|
import type { NotificationItem as NotificationItemType } from '@/api/notifications';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// ── helpers ──
|
// ── helpers ──
|
||||||
|
|
||||||
@@ -79,7 +78,6 @@ export interface NotificationItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
|
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const isUnread = notification.read_at == null;
|
const isUnread = notification.read_at == null;
|
||||||
const Icon = getIconForType(notification.type);
|
const Icon = getIconForType(notification.type);
|
||||||
|
|
||||||
@@ -125,8 +123,8 @@ export function NotificationItem({ notification, onMarkRead }: NotificationItemP
|
|||||||
{isUnread && (
|
{isUnread && (
|
||||||
<span
|
<span
|
||||||
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
|
className="flex-shrink-0 w-2 h-2 rounded-full bg-primary-500"
|
||||||
aria-label={t('notificationItem.ungelesen')}
|
aria-label="ungelesen"
|
||||||
title={t('notificationItem.ungelesen')}
|
title="ungelesen"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Loader2 } from 'lucide-react';
|
|||||||
import { usePluginStore } from '@/store/pluginStore';
|
import { usePluginStore } from '@/store/pluginStore';
|
||||||
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
|
||||||
import { PluginPage } from './PluginLoader';
|
import { PluginPage } from './PluginLoader';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PluginRouteRenderer — catch-all route handler that checks the current URL
|
* PluginRouteRenderer — catch-all route handler that checks the current URL
|
||||||
@@ -18,7 +17,6 @@ import { useTranslation } from 'react-i18next';
|
|||||||
* { path: '*', element: <PluginRouteRenderer /> }
|
* { path: '*', element: <PluginRouteRenderer /> }
|
||||||
*/
|
*/
|
||||||
export function PluginRouteRenderer() {
|
export function PluginRouteRenderer() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const manifests = usePluginStore((s) => s.manifests);
|
const manifests = usePluginStore((s) => s.manifests);
|
||||||
const loaded = usePluginStore((s) => s.loaded);
|
const loaded = usePluginStore((s) => s.loaded);
|
||||||
@@ -65,7 +63,7 @@ export function PluginRouteRenderer() {
|
|||||||
// If manifests haven't loaded yet, show a spinner (not null/blank)
|
// If manifests haven't loaded yet, show a spinner (not null/blank)
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label={t('pluginRouteRenderer.loading')}>
|
<div className="flex items-center justify-center min-h-[50vh]" role="status" aria-label="Loading">
|
||||||
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -74,9 +72,9 @@ export function PluginRouteRenderer() {
|
|||||||
// No plugin route matched — show a simple not-found
|
// No plugin route matched — show a simple not-found
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-secondary-500">
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-secondary-500">
|
||||||
<h2 className="text-2xl font-semibold mb-2">{t('pluginRouteRenderer.pagenotfound')}</h2>
|
<h2 className="text-2xl font-semibold mb-2">Page Not Found</h2>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{t('pluginRouteRenderer.thepage')} <code className="bg-secondary-100 px-1 rounded">{location.pathname}</code> {t('pluginRouteRenderer.wasnotfound')}
|
The page <code className="bg-secondary-100 px-1 rounded">{location.pathname}</code> was not found.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,21 +111,21 @@ export function WorkspaceManager() {
|
|||||||
<h3 className="font-medium">{t('workspaces.createNew', 'Neuen Workspace erstellen')}</h3>
|
<h3 className="font-medium">{t('workspaces.createNew', 'Neuen Workspace erstellen')}</h3>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('workspaceManager.name')}
|
placeholder="Name"
|
||||||
value={editName}
|
value={editName}
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setEditName(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('workspaceManager.beschreibung')}
|
placeholder="Beschreibung"
|
||||||
value={editDesc}
|
value={editDesc}
|
||||||
onChange={e => setEditDesc(e.target.value)}
|
onChange={e => setEditDesc(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-sm"
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
||||||
{t('workspaceManager.alsstandardworkspace')}
|
Als Standard-Workspace
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={handleCreate} disabled={!editName} className="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
<button onClick={handleCreate} disabled={!editName} className="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
||||||
@@ -169,7 +169,7 @@ export function WorkspaceManager() {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); setModuleConfig(prev => prev.map(x => x.module_key === m.module_key ? { ...x, config: x.config } : x)); setConfigEditingKey(configEditingKey === m.module_key ? null : m.module_key); }}
|
onClick={(e) => { e.preventDefault(); setModuleConfig(prev => prev.map(x => x.module_key === m.module_key ? { ...x, config: x.config } : x)); setConfigEditingKey(configEditingKey === m.module_key ? null : m.module_key); }}
|
||||||
className="text-xs px-1.5 py-0.5 border rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="text-xs px-1.5 py-0.5 border rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
title={t('workspaceManager.konfigurationbearbeiten')}
|
title="Konfiguration bearbeiten"
|
||||||
>
|
>
|
||||||
⚙
|
⚙
|
||||||
</button>
|
</button>
|
||||||
@@ -179,7 +179,7 @@ export function WorkspaceManager() {
|
|||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full text-xs font-mono p-1.5 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 h-20"
|
className="w-full text-xs font-mono p-1.5 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 h-20"
|
||||||
placeholder={t('workspaceManager.visiblefolderids')}
|
placeholder='{"visible_folder_ids": []}'
|
||||||
value={JSON.stringify(m.config || {}, null, 2)}
|
value={JSON.stringify(m.config || {}, null, 2)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
try {
|
try {
|
||||||
@@ -190,7 +190,7 @@ export function WorkspaceManager() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400">{t('workspaceManager.jsonkonfigurationfürdiesesmodulz')}</p>
|
<p className="text-xs text-gray-400">JSON-Konfiguration für dieses Modul (z.B. sichtbare Ordner-IDs)</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +211,7 @@ export function WorkspaceManager() {
|
|||||||
{editingId === ws.id ? (
|
{editingId === ws.id ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input type="text" value={editName} onChange={e => setEditName(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" />
|
<input type="text" value={editName} onChange={e => setEditName(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" />
|
||||||
<input type="text" value={editDesc} onChange={e => setEditDesc(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" placeholder={t('workspaceManager.beschreibung')} />
|
<input type="text" value={editDesc} onChange={e => setEditDesc(e.target.value)} className="w-full px-3 py-2 border rounded-md text-sm" placeholder="Beschreibung" />
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
<input type="checkbox" checked={editDefault} onChange={e => setEditDefault(e.target.checked)} />
|
||||||
Standard
|
Standard
|
||||||
@@ -246,21 +246,21 @@ export function WorkspaceManager() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => openModuleEditor(ws)}
|
onClick={() => openModuleEditor(ws)}
|
||||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
|
||||||
title={t('workspaceManager.module')}
|
title="Module"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="w-4 h-4" />
|
<LayoutGrid className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingId(ws.id); setEditName(ws.name); setEditDesc(ws.description || ''); setEditDefault(ws.is_default); }}
|
onClick={() => { setEditingId(ws.id); setEditName(ws.name); setEditDesc(ws.description || ''); setEditDefault(ws.is_default); }}
|
||||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md text-sm"
|
||||||
title={t('workspaceManager.bearbeiten')}
|
title="Bearbeiten"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(ws.id)}
|
onClick={() => handleDelete(ws.id)}
|
||||||
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md text-sm text-red-600"
|
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md text-sm text-red-600"
|
||||||
title={t('workspaceManager.löschen')}
|
title="Löschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export interface ActivityItem {
|
export interface ActivityItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,12 +17,11 @@ export interface ActivityFeedProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityFeed({ activities, title = 'Letzte Aktivitäten', maxItems = 10 }: ActivityFeedProps) {
|
export function ActivityFeed({ activities, title = 'Letzte Aktivitäten', maxItems = 10 }: ActivityFeedProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const visible = activities.slice(0, maxItems);
|
const visible = activities.slice(0, maxItems);
|
||||||
return (
|
return (
|
||||||
<Card title={title} data-testid="activity-feed">
|
<Card title={title} data-testid="activity-feed">
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<p className="text-sm text-secondary-500 py-4 text-center">{t('activityFeed.keineaktivitätenvorhanden')}</p>
|
<p className="text-sm text-secondary-500 py-4 text-center">Keine Aktivitäten vorhanden.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3" role="list">
|
<ul className="space-y-3" role="list">
|
||||||
{visible.map((activity) => (
|
{visible.map((activity) => (
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function DataGrid<T extends Record<string, any>>({
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="overflow-x-auto"
|
className="overflow-x-auto"
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('dataGrid.datagrid')}
|
aria-label="Data grid"
|
||||||
style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}
|
style={shouldVirtualize ? { maxHeight: '70vh', overflowY: 'auto' } : undefined}
|
||||||
>
|
>
|
||||||
<table className="min-w-full divide-y divide-secondary-200">
|
<table className="min-w-full divide-y divide-secondary-200">
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function SearchDropdown({ placeholder }: SearchDropdownProps) {
|
|||||||
onClick={handleSeeAll}
|
onClick={handleSeeAll}
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 font-medium min-h-touch"
|
className="text-sm text-primary-600 hover:text-primary-700 font-medium min-h-touch"
|
||||||
>
|
>
|
||||||
{t('searchDropdown.alleergebnisseanzeigen')}
|
Alle Ergebnisse anzeigen →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,14 +10,12 @@ import React from 'react';
|
|||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
import { useUsers, useGroups } from '@/api/hooks';
|
import { useUsers, useGroups } from '@/api/hooks';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface SharedTeamPanelProps {
|
interface SharedTeamPanelProps {
|
||||||
onStartDirectChat?: (userId: string, userName: string) => void;
|
onStartDirectChat?: (userId: string, userName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: usersData, isLoading: usersLoading } = useUsers();
|
const { data: usersData, isLoading: usersLoading } = useUsers();
|
||||||
const { data: groupsData, isLoading: groupsLoading } = useGroups();
|
const { data: groupsData, isLoading: groupsLoading } = useGroups();
|
||||||
const users: any[] = usersData?.items || [];
|
const users: any[] = usersData?.items || [];
|
||||||
@@ -30,7 +28,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
|||||||
{usersLoading ? (
|
{usersLoading ? (
|
||||||
<p className="text-sm text-secondary-400">Laden...</p>
|
<p className="text-sm text-secondary-400">Laden...</p>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<p className="text-sm text-secondary-400">{t('teamPanel.keinemitarbeiter')}</p>
|
<p className="text-sm text-secondary-400">Keine Mitarbeiter</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{users.map((u) =>
|
{users.map((u) =>
|
||||||
@@ -42,7 +40,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
|||||||
>
|
>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<Avatar name={u.name} size="sm" />
|
<Avatar name={u.name} size="sm" />
|
||||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label={t('teamPanel.offline')} />
|
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label="offline" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
|
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
|
||||||
@@ -54,7 +52,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
|||||||
<div key={u.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors">
|
<div key={u.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<Avatar name={u.name} size="sm" />
|
<Avatar name={u.name} size="sm" />
|
||||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label={t('teamPanel.offline')} />
|
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label="offline" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
|
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
|
||||||
@@ -72,7 +70,7 @@ export function SharedTeamPanel({ onStartDirectChat }: SharedTeamPanelProps) {
|
|||||||
{groupsLoading ? (
|
{groupsLoading ? (
|
||||||
<p className="text-sm text-secondary-400">Laden...</p>
|
<p className="text-sm text-secondary-400">Laden...</p>
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<p className="text-sm text-secondary-400">{t('teamPanel.keinegruppen')}</p>
|
<p className="text-sm text-secondary-400">Keine Gruppen</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
|
|||||||
id="assignee-id"
|
id="assignee-id"
|
||||||
value={assigneeId}
|
value={assigneeId}
|
||||||
onChange={(e) => setAssigneeId(e.target.value)}
|
onChange={(e) => setAssigneeId(e.target.value)}
|
||||||
placeholder={t('taskDetail.uuid')}
|
placeholder="UUID"
|
||||||
className="w-64"
|
className="w-64"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export interface ModalProps {
|
export interface ModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -33,7 +32,6 @@ export function Modal({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
fullScreenMobile = false,
|
fullScreenMobile = false,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -100,7 +98,7 @@ export function Modal({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-3 right-3 md:top-4 md:right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
className="absolute top-3 right-3 md:top-4 md:right-4 text-secondary-400 hover:text-secondary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
aria-label={t('modal.closedialog')}
|
aria-label="Close dialog"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" aria-hidden="true" />
|
<X className="h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function Pagination({ currentPage, totalPages, total, pageSize, onPageCha
|
|||||||
if (totalPages <= 1) return null;
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex items-center justify-between px-3 py-1.5 border-t border-secondary-200 bg-white" aria-label={t('pagination.pagination')}>
|
<nav className="flex items-center justify-between px-3 py-1.5 border-t border-secondary-200 bg-white" aria-label="Pagination">
|
||||||
<div className="text-xs text-secondary-500">
|
<div className="text-xs text-secondary-500">
|
||||||
<span>{start}–{end}</span> <span>{t('table.of')}</span> <span>{total}</span>
|
<span>{start}–{end}</span> <span>{t('table.of')}</span> <span>{total}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export interface SkeletonProps {
|
export interface SkeletonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -10,7 +9,6 @@ export interface SkeletonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Skeleton({ className, variant = 'rect', width, height }: SkeletonProps) {
|
export function Skeleton({ className, variant = 'rect', width, height }: SkeletonProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const variantClass = {
|
const variantClass = {
|
||||||
text: 'rounded',
|
text: 'rounded',
|
||||||
rect: 'rounded-md',
|
rect: 'rounded-md',
|
||||||
@@ -26,15 +24,14 @@ export function Skeleton({ className, variant = 'rect', width, height }: Skeleto
|
|||||||
)}
|
)}
|
||||||
style={{ width, height }}
|
style={{ width, height }}
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={t('skeleton.wirdgeladen')}
|
aria-label="Wird geladen"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
|
export function SkeletonText({ lines = 3, className }: { lines?: number; className?: string }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('space-y-2', className)} role="status" aria-label={t('skeleton.wirdgeladen')}>
|
<div className={clsx('space-y-2', className)} role="status" aria-label="Wird geladen">
|
||||||
{Array.from({ length: lines }).map((_, i) => (
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={i}
|
key={i}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export interface TableColumn<T> {
|
export interface TableColumn<T> {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -31,7 +30,6 @@ export function Table<T extends Record<string, any>>({
|
|||||||
emptyMessage = 'Keine Daten vorhanden',
|
emptyMessage = 'Keine Daten vorhanden',
|
||||||
loading = false,
|
loading = false,
|
||||||
}: TableProps<T>) {
|
}: TableProps<T>) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
@@ -61,7 +59,7 @@ export function Table<T extends Record<string, any>>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto" role="region" aria-label={t('table.datatable')}>
|
<div className="overflow-x-auto" role="region" aria-label="Data table">
|
||||||
<table className="min-w-full divide-y divide-secondary-200">
|
<table className="min-w-full divide-y divide-secondary-200">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -104,7 +102,7 @@ export function Table<T extends Record<string, any>>({
|
|||||||
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
|
<td colSpan={columns.length} className="px-6 py-8 text-center text-secondary-500">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Loader2 className="animate-spin motion-reduce:animate-none h-5 w-5" aria-hidden="true" />
|
<Loader2 className="animate-spin motion-reduce:animate-none h-5 w-5" aria-hidden="true" />
|
||||||
{t('table.wirdgeladen')}
|
Wird geladen...
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useEffect, useCallback } from 'react';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Check, X, AlertTriangle, Info } from 'lucide-react';
|
import { Check, X, AlertTriangle, Info } from 'lucide-react';
|
||||||
import { useUIStore, Toast as ToastType } from '@/store/uiStore';
|
import { useUIStore, Toast as ToastType } from '@/store/uiStore';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const toastStyles: Record<ToastType['type'], string> = {
|
const toastStyles: Record<ToastType['type'], string> = {
|
||||||
success: 'bg-success-50 border-success-500 text-success-800',
|
success: 'bg-success-50 border-success-500 text-success-800',
|
||||||
@@ -19,7 +18,6 @@ const toastIcons: Record<ToastType['type'], React.ComponentType<{ className?: st
|
|||||||
};
|
};
|
||||||
|
|
||||||
function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: string) => void }) {
|
function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: string) => void }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const duration = toast.duration ?? 5000;
|
const duration = toast.duration ?? 5000;
|
||||||
const timer = setTimeout(() => onRemove(toast.id), duration);
|
const timer = setTimeout(() => onRemove(toast.id), duration);
|
||||||
@@ -44,7 +42,7 @@ function ToastItem({ toast, onRemove }: { toast: ToastType; onRemove: (id: strin
|
|||||||
<button
|
<button
|
||||||
onClick={() => onRemove(toast.id)}
|
onClick={() => onRemove(toast.id)}
|
||||||
className="flex-shrink-0 text-current opacity-60 hover:opacity-100 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-current"
|
className="flex-shrink-0 text-current opacity-60 hover:opacity-100 min-h-touch min-w-touch flex items-center justify-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-current"
|
||||||
aria-label={t('toast.closenotification')}
|
aria-label="Close notification"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" aria-hidden="true" />
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|||||||
import { Sparkles, Send } from 'lucide-react';
|
import { Sparkles, Send } from 'lucide-react';
|
||||||
import { streamChat, fetchMessages } from '@/api/ai';
|
import { streamChat, fetchMessages } from '@/api/ai';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface AiChatPanelProps {
|
interface AiChatPanelProps {
|
||||||
windowTitle: string;
|
windowTitle: string;
|
||||||
@@ -17,7 +16,6 @@ interface SimpleMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [messages, setMessages] = useState<SimpleMessage[]>([]);
|
const [messages, setMessages] = useState<SimpleMessage[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
@@ -120,10 +118,10 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||||
<span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
|
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center text-sm text-secondary-400">
|
<div className="flex-1 flex items-center justify-center text-sm text-secondary-400">
|
||||||
{t('aiChatPanel.verbindemitki')}
|
Verbinde mit KI...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -134,7 +132,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||||
<span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
|
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center p-4 text-center">
|
<div className="flex-1 flex items-center justify-center p-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
@@ -151,7 +149,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
<div className="px-4 py-2 border-b border-secondary-200 bg-secondary-50 flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||||
<span className="text-sm font-semibold text-secondary-700">{t('aiChatPanel.kiassistent')}</span>
|
<span className="text-sm font-semibold text-secondary-700">KI Assistent</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context info */}
|
{/* Context info */}
|
||||||
@@ -163,7 +161,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
|||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-2">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{messages.length === 0 && !streamingContent && (
|
{messages.length === 0 && !streamingContent && (
|
||||||
<p className="text-sm text-secondary-400 text-center mt-4">
|
<p className="text-sm text-secondary-400 text-center mt-4">
|
||||||
{t('aiChatPanel.stelleeinefragezumaktuellenfenster')}
|
Stelle eine Frage zum aktuellen Fenster...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
@@ -195,7 +193,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={t('aiChatPanel.nachrichteingeben')}
|
placeholder="Nachricht eingeben..."
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
className="flex-1 px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 resize-none"
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
@@ -204,7 +202,7 @@ export function AiChatPanel({ windowTitle, windowType }: AiChatPanelProps) {
|
|||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim() || isStreaming}
|
disabled={!input.trim() || isStreaming}
|
||||||
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="p-2 rounded-md bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
aria-label={t('aiChatPanel.senden')}
|
aria-label="Senden"
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import clsx from 'clsx';
|
|||||||
import { Sparkles, Maximize2, Minimize2, Minus, X } from 'lucide-react';
|
import { Sparkles, Maximize2, Minimize2, Minus, X } from 'lucide-react';
|
||||||
import { useWindowStore, type WindowState } from '@/store/windowStore';
|
import { useWindowStore, type WindowState } from '@/store/windowStore';
|
||||||
import { AiChatPanel } from './AiChatPanel';
|
import { AiChatPanel } from './AiChatPanel';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface WindowProps {
|
interface WindowProps {
|
||||||
window: WindowState;
|
window: WindowState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Window({ window: win }: WindowProps) {
|
export function Window({ window: win }: WindowProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
const {
|
||||||
closeWindow,
|
closeWindow,
|
||||||
minimizeWindow,
|
minimizeWindow,
|
||||||
@@ -108,8 +106,8 @@ export function Window({ window: win }: WindowProps) {
|
|||||||
'p-1.5 rounded hover:bg-secondary-200',
|
'p-1.5 rounded hover:bg-secondary-200',
|
||||||
win.aiChatVisible && 'bg-primary-100 text-primary-600'
|
win.aiChatVisible && 'bg-primary-100 text-primary-600'
|
||||||
)}
|
)}
|
||||||
aria-label={t('window.kichateinaus')}
|
aria-label="KI Chat ein/aus"
|
||||||
title={t('window.kichat')}
|
title="KI Chat"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -130,8 +128,8 @@ export function Window({ window: win }: WindowProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => minimizeWindow(win.id)}
|
onClick={() => minimizeWindow(win.id)}
|
||||||
className="p-1.5 rounded hover:bg-secondary-200"
|
className="p-1.5 rounded hover:bg-secondary-200"
|
||||||
aria-label={t('window.minimieren')}
|
aria-label="Minimieren"
|
||||||
title={t('window.minimieren')}
|
title="Minimieren"
|
||||||
>
|
>
|
||||||
<Minus className="w-4 h-4" />
|
<Minus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -139,8 +137,8 @@ export function Window({ window: win }: WindowProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => closeWindow(win.id)}
|
onClick={() => closeWindow(win.id)}
|
||||||
className="p-1.5 rounded hover:bg-danger-100 hover:text-danger-600"
|
className="p-1.5 rounded hover:bg-danger-100 hover:text-danger-600"
|
||||||
aria-label={t('window.schließen')}
|
aria-label="Schließen"
|
||||||
title={t('window.schließen')}
|
title="Schließen"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Select } from '@/components/ui/Select';
|
|||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Code2, FormInput } from 'lucide-react';
|
import { Code2, FormInput } from 'lucide-react';
|
||||||
import type { WorkflowStep, WorkflowStepType } from '@/api/workflows';
|
import type { WorkflowStep, WorkflowStepType } from '@/api/workflows';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const stepTypeOptions: { value: WorkflowStepType; label: string }[] = [
|
const stepTypeOptions: { value: WorkflowStepType; label: string }[] = [
|
||||||
{ value: 'action', label: 'Action' },
|
{ value: 'action', label: 'Action' },
|
||||||
@@ -54,7 +53,6 @@ export interface StepConfigPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [mode, setMode] = useState<ConfigMode>('form');
|
const [mode, setMode] = useState<ConfigMode>('form');
|
||||||
const [configText, setConfigText] = useState('');
|
const [configText, setConfigText] = useState('');
|
||||||
const [configError, setConfigError] = useState<string | undefined>(undefined);
|
const [configError, setConfigError] = useState<string | undefined>(undefined);
|
||||||
@@ -113,7 +111,6 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderTypeForm = () => {
|
const renderTypeForm = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
switch (step.type) {
|
switch (step.type) {
|
||||||
case 'wait':
|
case 'wait':
|
||||||
return (
|
return (
|
||||||
@@ -129,13 +126,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
e.target.value === '' ? undefined : Number(e.target.value)
|
e.target.value === '' ? undefined : Number(e.target.value)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder={t('stepConfigPanel.zb3600')}
|
placeholder="z.B. 3600"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Resume-Zeitpunkt (ISO)"
|
label="Resume-Zeitpunkt (ISO)"
|
||||||
value={strVal('resume_at')}
|
value={strVal('resume_at')}
|
||||||
onChange={(e) => setConfig('resume_at', e.target.value || undefined)}
|
onChange={(e) => setConfig('resume_at', e.target.value || undefined)}
|
||||||
placeholder={t('stepConfigPanel.20260818t090000z')}
|
placeholder="2026-08-18T09:00:00Z"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -154,7 +151,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
required
|
required
|
||||||
value={strVal('url')}
|
value={strVal('url')}
|
||||||
onChange={(e) => setConfig('url', e.target.value)}
|
onChange={(e) => setConfig('url', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.httpsapiexamplecomwebhook')}
|
placeholder="https://api.example.com/webhook"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<JsonField
|
<JsonField
|
||||||
@@ -171,7 +168,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
onChange={(e) => setConfig('body', e.target.value)}
|
onChange={(e) => setConfig('body', e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('stepConfigPanel.keyvalue')}
|
placeholder='{"key": "value"}'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -197,14 +194,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
required
|
required
|
||||||
value={strVal('to')}
|
value={strVal('to')}
|
||||||
onChange={(e) => setConfig('to', e.target.value)}
|
onChange={(e) => setConfig('to', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.empfaengerexamplecom')}
|
placeholder="empfaenger@example.com"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Betreff"
|
label="Betreff"
|
||||||
required
|
required
|
||||||
value={strVal('subject')}
|
value={strVal('subject')}
|
||||||
onChange={(e) => setConfig('subject', e.target.value)}
|
onChange={(e) => setConfig('subject', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.betreff')}
|
placeholder="Betreff"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -216,14 +213,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
onChange={(e) => setConfig('body', e.target.value)}
|
onChange={(e) => setConfig('body', e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('stepConfigPanel.nachrichtentext')}
|
placeholder="Nachrichtentext"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label="Account-ID (optional)"
|
label="Account-ID (optional)"
|
||||||
value={strVal('account_id')}
|
value={strVal('account_id')}
|
||||||
onChange={(e) => setConfig('account_id', e.target.value || undefined)}
|
onChange={(e) => setConfig('account_id', e.target.value || undefined)}
|
||||||
placeholder={t('stepConfigPanel.standardkontowennleer')}
|
placeholder="Standard-Konto wenn leer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -243,19 +240,19 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
label="Titel"
|
label="Titel"
|
||||||
value={strVal('title')}
|
value={strVal('title')}
|
||||||
onChange={(e) => setConfig('title', e.target.value)}
|
onChange={(e) => setConfig('title', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.eventtitel')}
|
placeholder="Event-Titel"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Start (ISO)"
|
label="Start (ISO)"
|
||||||
value={strVal('start')}
|
value={strVal('start')}
|
||||||
onChange={(e) => setConfig('start', e.target.value)}
|
onChange={(e) => setConfig('start', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.20260818t090000z')}
|
placeholder="2026-08-18T09:00:00Z"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Ende (ISO)"
|
label="Ende (ISO)"
|
||||||
value={strVal('end')}
|
value={strVal('end')}
|
||||||
onChange={(e) => setConfig('end', e.target.value)}
|
onChange={(e) => setConfig('end', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.20260818t100000z')}
|
placeholder="2026-08-18T10:00:00Z"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -264,7 +261,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
label="Event-ID"
|
label="Event-ID"
|
||||||
value={strVal('event_id')}
|
value={strVal('event_id')}
|
||||||
onChange={(e) => setConfig('event_id', e.target.value)}
|
onChange={(e) => setConfig('event_id', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.eventuuid')}
|
placeholder="Event-UUID"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,7 +282,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
label="Suchbegriff"
|
label="Suchbegriff"
|
||||||
value={strVal('query')}
|
value={strVal('query')}
|
||||||
onChange={(e) => setConfig('query', e.target.value)}
|
onChange={(e) => setConfig('query', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.suchbegriff')}
|
placeholder="Suchbegriff"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{action === 'download' && (
|
{action === 'download' && (
|
||||||
@@ -293,7 +290,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
label="Datei-ID"
|
label="Datei-ID"
|
||||||
value={strVal('file_id')}
|
value={strVal('file_id')}
|
||||||
onChange={(e) => setConfig('file_id', e.target.value)}
|
onChange={(e) => setConfig('file_id', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.dateiuuid')}
|
placeholder="Datei-UUID"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{action === 'upload' && (
|
{action === 'upload' && (
|
||||||
@@ -302,13 +299,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
label="Dateiname"
|
label="Dateiname"
|
||||||
value={strVal('file_name')}
|
value={strVal('file_name')}
|
||||||
onChange={(e) => setConfig('file_name', e.target.value)}
|
onChange={(e) => setConfig('file_name', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.dateipdf')}
|
placeholder="datei.pdf"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Inhalt"
|
label="Inhalt"
|
||||||
value={strVal('content')}
|
value={strVal('content')}
|
||||||
onChange={(e) => setConfig('content', e.target.value)}
|
onChange={(e) => setConfig('content', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.dateiinhalt')}
|
placeholder="Dateiinhalt"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -323,13 +320,13 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
required
|
required
|
||||||
value={strVal('query')}
|
value={strVal('query')}
|
||||||
onChange={(e) => setConfig('query', e.target.value)}
|
onChange={(e) => setConfig('query', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.suchbegriff')}
|
placeholder="Suchbegriff"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Entity-Typ (optional)"
|
label="Entity-Typ (optional)"
|
||||||
value={strVal('entity_type')}
|
value={strVal('entity_type')}
|
||||||
onChange={(e) => setConfig('entity_type', e.target.value || undefined)}
|
onChange={(e) => setConfig('entity_type', e.target.value || undefined)}
|
||||||
placeholder={t('stepConfigPanel.contactcompanyfile')}
|
placeholder="contact, company, file, ..."
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Limit"
|
label="Limit"
|
||||||
@@ -350,7 +347,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
required
|
required
|
||||||
value={strVal('agent_id')}
|
value={strVal('agent_id')}
|
||||||
onChange={(e) => setConfig('agent_id', e.target.value)}
|
onChange={(e) => setConfig('agent_id', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.agentuuid')}
|
placeholder="Agent-UUID"
|
||||||
/>
|
/>
|
||||||
<JsonField
|
<JsonField
|
||||||
label="Input (JSON)"
|
label="Input (JSON)"
|
||||||
@@ -364,7 +361,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
onChange={(e) => setConfig('wait_for_completion', e.target.checked)}
|
onChange={(e) => setConfig('wait_for_completion', e.target.checked)}
|
||||||
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
{t('stepConfigPanel.aufabschlusswarten')}
|
Auf Abschluss warten
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -383,7 +380,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
label="Entity-ID"
|
label="Entity-ID"
|
||||||
value={strVal('entity_id')}
|
value={strVal('entity_id')}
|
||||||
onChange={(e) => setConfig('entity_id', e.target.value)}
|
onChange={(e) => setConfig('entity_id', e.target.value)}
|
||||||
placeholder={t('stepConfigPanel.entityuuid')}
|
placeholder="Entity-UUID"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(action.includes('create') || action.includes('update')) && (
|
{(action.includes('create') || action.includes('update')) && (
|
||||||
@@ -400,14 +397,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
{t('stepConfigPanel.konfigurationjson')}
|
Konfiguration (JSON)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={configText}
|
value={configText}
|
||||||
onChange={(e) => handleConfigChange(e.target.value)}
|
onChange={(e) => handleConfigChange(e.target.value)}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('stepConfigPanel.keyvalue')}
|
placeholder='{"key": "value"}'
|
||||||
/>
|
/>
|
||||||
{configError && (
|
{configError && (
|
||||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||||
@@ -427,7 +424,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
required
|
required
|
||||||
value={step.name}
|
value={step.name}
|
||||||
onChange={(e) => onChange({ ...step, name: e.target.value })}
|
onChange={(e) => onChange({ ...step, name: e.target.value })}
|
||||||
placeholder={t('stepConfigPanel.zbgenehmigungeinholen')}
|
placeholder="z.B. Genehmigung einholen"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Typ"
|
label="Typ"
|
||||||
@@ -448,7 +445,7 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
onChange={(e) => onChange({ ...step, description: e.target.value || null })}
|
onChange={(e) => onChange({ ...step, description: e.target.value || null })}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('stepConfigPanel.optionalebeschreibung')}
|
placeholder="Optionale Beschreibung"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -487,14 +484,14 @@ export function StepConfigPanel({ step, onChange }: StepConfigPanelProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
{t('stepConfigPanel.konfigurationjson')}
|
Konfiguration (JSON)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={configText}
|
value={configText}
|
||||||
onChange={(e) => handleConfigChange(e.target.value)}
|
onChange={(e) => handleConfigChange(e.target.value)}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('stepConfigPanel.keyvalue')}
|
placeholder='{"key": "value"}'
|
||||||
/>
|
/>
|
||||||
{configError && (
|
{configError && (
|
||||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||||
@@ -514,7 +511,6 @@ interface JsonFieldProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function JsonField({ label, value, onChange }: JsonFieldProps) {
|
function JsonField({ label, value, onChange }: JsonFieldProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [text, setText] = useState(value);
|
const [text, setText] = useState(value);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
@@ -547,7 +543,7 @@ function JsonField({ label, value, onChange }: JsonFieldProps) {
|
|||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('stepConfigPanel.keyvalue')}
|
placeholder='{"key": "value"}'
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
}`}
|
}`}
|
||||||
aria-pressed={mode === 'json'}
|
aria-pressed={mode === 'json'}
|
||||||
>
|
>
|
||||||
<Code2 className="h-3.5 w-3.5" /> {t('workflowEditor.jsonexpert')}
|
<Code2 className="h-3.5 w-3.5" /> JSON Expert
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,7 +428,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
required
|
required
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => updateField('name', e.target.value)}
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
placeholder={t('workflowEditor.zbdealgenehmigungsprozess')}
|
placeholder="z.B. Deal-Genehmigungsprozess"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
@@ -439,7 +439,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
onChange={(e) => updateField('description', e.target.value)}
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('workflowEditor.optionalebeschreibung')}
|
placeholder="Optionale Beschreibung"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
@@ -472,12 +472,12 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
type="button"
|
type="button"
|
||||||
icon={<Plus className="h-4 w-4" />}
|
icon={<Plus className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
{t('workflowEditor.schritthinzufuegen')}
|
Schritt hinzufuegen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{form.steps.length === 0 && (
|
{form.steps.length === 0 && (
|
||||||
<p className="text-sm text-secondary-400 italic">
|
<p className="text-sm text-secondary-400 italic">
|
||||||
{t('workflowEditor.keineschrittedefiniertklickeaufschritt')}
|
Keine Schritte definiert. Klicke auf Schritt hinzufuegen.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -493,7 +493,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
onClick={() => moveStep(i, 'up')}
|
onClick={() => moveStep(i, 'up')}
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||||
aria-label={t('workflowEditor.nachoben')}
|
aria-label="Nach oben"
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -502,7 +502,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
onClick={() => moveStep(i, 'down')}
|
onClick={() => moveStep(i, 'down')}
|
||||||
disabled={i === form.steps.length - 1}
|
disabled={i === form.steps.length - 1}
|
||||||
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
className="text-secondary-400 hover:text-secondary-700 disabled:opacity-30 p-1"
|
||||||
aria-label={t('workflowEditor.nachunten')}
|
aria-label="Nach unten"
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -510,7 +510,7 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeStep(i)}
|
onClick={() => removeStep(i)}
|
||||||
className="text-danger-500 hover:text-danger-700 p-1"
|
className="text-danger-500 hover:text-danger-700 p-1"
|
||||||
aria-label={t('workflowEditor.schrittentfernen')}
|
aria-label="Schritt entfernen"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -528,14 +528,14 @@ export function WorkflowEditor({ open, workflow, onClose }: WorkflowEditorProps)
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||||
{t('workflowEditor.workflowjson')}
|
Workflow (JSON)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={jsonText}
|
value={jsonText}
|
||||||
onChange={(e) => handleJsonChange(e.target.value)}
|
onChange={(e) => handleJsonChange(e.target.value)}
|
||||||
rows={18}
|
rows={18}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('workflowEditor.namesteps')}
|
placeholder='{"name": "...", "steps": [...]}'
|
||||||
/>
|
/>
|
||||||
{jsonError && (
|
{jsonError && (
|
||||||
<p className="mt-1 text-sm text-danger-600" role="alert">
|
<p className="mt-1 text-sm text-danger-600" role="alert">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Skeleton } from '@/components/ui/Skeleton';
|
|||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { AlertCircle, ChevronRight } from 'lucide-react';
|
import { AlertCircle, ChevronRight } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const statusFilterOptions = [
|
const statusFilterOptions = [
|
||||||
{ value: '', label: 'Alle Status' },
|
{ value: '', label: 'Alle Status' },
|
||||||
@@ -55,7 +54,6 @@ export interface WorkflowInstanceListProps {
|
|||||||
export function WorkflowInstanceList({
|
export function WorkflowInstanceList({
|
||||||
onSelectInstance,
|
onSelectInstance,
|
||||||
}: WorkflowInstanceListProps) {
|
}: WorkflowInstanceListProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
@@ -105,12 +103,12 @@ export function WorkflowInstanceList({
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center gap-3 text-danger-600">
|
<div className="flex items-center gap-3 text-danger-600">
|
||||||
<AlertCircle className="h-5 w-5" />
|
<AlertCircle className="h-5 w-5" />
|
||||||
<span>{t('workflowInstanceList.fehlerbeimladenderinstanzen')}</span>
|
<span>Fehler beim Laden der Instanzen</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="text-sm text-primary-600 hover:underline"
|
className="text-sm text-primary-600 hover:underline"
|
||||||
>
|
>
|
||||||
{t('workflowInstanceList.erneutversuchen')}
|
Erneut versuchen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -119,7 +117,7 @@ export function WorkflowInstanceList({
|
|||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!isLoading && !isError && instances.length === 0 && (
|
{!isLoading && !isError && instances.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('workflowInstanceList.keineworkflowinstanzen')}
|
title="Keine Workflow-Instanzen"
|
||||||
description="Es wurden keine Instanzen gefunden, die dem Filter entsprechen."
|
description="Es wurden keine Instanzen gefunden, die dem Filter entsprechen."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
{
|
{
|
||||||
"app": {
|
"app": {
|
||||||
"name": "leocrm",
|
"name": "leocrm",
|
||||||
"tagline": "Mini-CRM für kleine Unternehmen",
|
"tagline": "Mini-CRM für kleine Unternehmen"
|
||||||
"siesindofflineänderungenwerdengespeicher": "Sie sind offline. Änderungen werden gespeichert wenn die Verbindung wiederhergestellt ist.",
|
|
||||||
"zumhauptinhaltspringen": "Zum Hauptinhalt springen"
|
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -140,10 +138,7 @@
|
|||||||
"down": "Nicht verfügbar",
|
"down": "Nicht verfügbar",
|
||||||
"unknown": "Unbekannt"
|
"unknown": "Unbekannt"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"llmcost24h": "LLM Cost (24h)",
|
|
||||||
"llmtokens24h": "LLM Tokens (24h)",
|
|
||||||
"activeplugins": "Active Plugins"
|
|
||||||
},
|
},
|
||||||
"companies": {
|
"companies": {
|
||||||
"title": "Firmen",
|
"title": "Firmen",
|
||||||
@@ -336,8 +331,7 @@
|
|||||||
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
|
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
|
||||||
"resetTheme": "Zurücksetzen",
|
"resetTheme": "Zurücksetzen",
|
||||||
"saveTheme": "Theme speichern",
|
"saveTheme": "Theme speichern",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP"
|
||||||
"zurückzurstartseite": "Zurück zur Startseite"
|
|
||||||
},
|
},
|
||||||
"auditLog": {
|
"auditLog": {
|
||||||
"title": "Audit-Log",
|
"title": "Audit-Log",
|
||||||
@@ -350,10 +344,7 @@
|
|||||||
"timestamp": "Zeitpunkt",
|
"timestamp": "Zeitpunkt",
|
||||||
"empty": "Keine Audit-Log-Einträge vorhanden.",
|
"empty": "Keine Audit-Log-Einträge vorhanden.",
|
||||||
"notAvailable": "Audit-Log ist aktuell nicht verfügbar.",
|
"notAvailable": "Audit-Log ist aktuell nicht verfügbar.",
|
||||||
"entityId": "Entität-ID",
|
"entityId": "Entität-ID"
|
||||||
"annaschmidt": "anna.schmidt",
|
|
||||||
"createupdatedelete": "create, update, delete",
|
|
||||||
"companycontact": "company, contact"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"title": "Suchergebnisse",
|
"title": "Suchergebnisse",
|
||||||
@@ -407,8 +398,7 @@
|
|||||||
"success": "Erfolg",
|
"success": "Erfolg",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
"warning": "Warnung",
|
"warning": "Warnung",
|
||||||
"info": "Information",
|
"info": "Information"
|
||||||
"closenotification": "Close notification"
|
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"page": "Seite",
|
"page": "Seite",
|
||||||
@@ -420,9 +410,7 @@
|
|||||||
"sortAscending": "Aufsteigend sortieren",
|
"sortAscending": "Aufsteigend sortieren",
|
||||||
"sortDescending": "Absteigend sortieren",
|
"sortDescending": "Absteigend sortieren",
|
||||||
"sortedBy": "Sortiert nach",
|
"sortedBy": "Sortiert nach",
|
||||||
"empty": "Keine Daten vorhanden",
|
"empty": "Keine Daten vorhanden"
|
||||||
"datatable": "Data table",
|
|
||||||
"wirdgeladen": "Wird geladen..."
|
|
||||||
},
|
},
|
||||||
"confirmDialog": {
|
"confirmDialog": {
|
||||||
"title": "Bestätigung erforderlich",
|
"title": "Bestätigung erforderlich",
|
||||||
@@ -434,8 +422,7 @@
|
|||||||
"next": "Weiter",
|
"next": "Weiter",
|
||||||
"page": "Seite {{page}}",
|
"page": "Seite {{page}}",
|
||||||
"first": "Erste Seite",
|
"first": "Erste Seite",
|
||||||
"last": "Letzte Seite",
|
"last": "Letzte Seite"
|
||||||
"pagination": "Pagination"
|
|
||||||
},
|
},
|
||||||
"emptyState": {
|
"emptyState": {
|
||||||
"title": "Nichts gefunden",
|
"title": "Nichts gefunden",
|
||||||
@@ -720,9 +707,7 @@
|
|||||||
"sortDesc": "Absteigend",
|
"sortDesc": "Absteigend",
|
||||||
"syncFailed": "Synchronisierung fehlgeschlagen",
|
"syncFailed": "Synchronisierung fehlgeschlagen",
|
||||||
"syncing": "Synchronisiere...",
|
"syncing": "Synchronisiere...",
|
||||||
"autoSyncEnabled": "Auto-Sync aktiv",
|
"autoSyncEnabled": "Auto-Sync aktiv"
|
||||||
"zurückzuordnern": "Zurück zu Ordnern",
|
|
||||||
"zurückzurliste": "Zurück zur Liste"
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Benachrichtigungseinstellungen",
|
"title": "Benachrichtigungseinstellungen",
|
||||||
@@ -1108,8 +1093,7 @@
|
|||||||
"invalidJson": "Ungültige JSON-Daten",
|
"invalidJson": "Ungültige JSON-Daten",
|
||||||
"selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste",
|
"selectTemplateHint": "Wählen Sie eine Vorlage aus der Liste",
|
||||||
"downloadHistory": "Download-Verlauf",
|
"downloadHistory": "Download-Verlauf",
|
||||||
"noDownloads": "Noch keine Downloads",
|
"noDownloads": "Noch keine Downloads"
|
||||||
"keyvalue": "{\"key\": \"value\"}"
|
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Aufgaben",
|
"title": "Aufgaben",
|
||||||
@@ -1162,12 +1146,7 @@
|
|||||||
"removeDependency": "Abhängigkeit entfernen",
|
"removeDependency": "Abhängigkeit entfernen",
|
||||||
"targetDate": "Zieldatum",
|
"targetDate": "Zieldatum",
|
||||||
"progress": "Fortschritt",
|
"progress": "Fortschritt",
|
||||||
"milestones": "Meilensteine",
|
"milestones": "Meilensteine"
|
||||||
"nachstatus": "Nach Status",
|
|
||||||
"nachpriorität": "Nach Priorität",
|
|
||||||
"alletasks": "Alle Tasks (",
|
|
||||||
"inbearbeitung": "In Bearbeitung",
|
|
||||||
"keinetasks": "Keine Tasks"
|
|
||||||
},
|
},
|
||||||
"savedFilters": {
|
"savedFilters": {
|
||||||
"save": "Filter speichern",
|
"save": "Filter speichern",
|
||||||
@@ -1461,613 +1440,5 @@
|
|||||||
"targetRoom": "Ziel-Raum",
|
"targetRoom": "Ziel-Raum",
|
||||||
"targetRoomDescription": "Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.",
|
"targetRoomDescription": "Name des Raums in der Kommunikation, an den Status-Meldungen gesendet werden.",
|
||||||
"defaultRoomName": "Live KI"
|
"defaultRoomName": "Live KI"
|
||||||
},
|
|
||||||
"activityFilter": {
|
|
||||||
"benutzername": "Benutzername"
|
|
||||||
},
|
|
||||||
"improvementPanel": {
|
|
||||||
"signalesammeln": "Signale sammeln",
|
|
||||||
"keinesignaleklickensieaufsammeln": "Keine Signale. Klicken Sie auf „Sammeln\" um zu starten.",
|
|
||||||
"mustererkennen": "Muster erkennen",
|
|
||||||
"keinemustersammelnsiezuerstsignale": "Keine Muster. Sammeln Sie zuerst Signale und klicken Sie dann auf „Erkennen\".",
|
|
||||||
"keinevorschläge": "Keine Vorschläge.",
|
|
||||||
"evaluieren": "Evaluieren",
|
|
||||||
"aktivieren": "Aktivieren",
|
|
||||||
"rollback": "Rollback",
|
|
||||||
"impactmessen": "Impact messen"
|
|
||||||
},
|
|
||||||
"suggestionBadge": {
|
|
||||||
"kivorschläge": "KI Vorschläge"
|
|
||||||
},
|
|
||||||
"suggestionCard": {
|
|
||||||
"ignorieren": "Ignorieren",
|
|
||||||
"ausgeführt": "✓ Ausgeführt"
|
|
||||||
},
|
|
||||||
"suggestionSidebar": {
|
|
||||||
"keinevorschlägevorhanden": "Keine Vorschläge vorhanden",
|
|
||||||
"diekianalysiertdeinenkontext": "Die KI analysiert deinen Kontext...",
|
|
||||||
"kivorschläge": "KI Vorschläge"
|
|
||||||
},
|
|
||||||
"blockRenderer": {
|
|
||||||
"unbekannterblocktyp": "Unbekannter Block-Typ:"
|
|
||||||
},
|
|
||||||
"contactCardBlock": {
|
|
||||||
"kontaktanzeigen": "Kontakt anzeigen"
|
|
||||||
},
|
|
||||||
"miniAppBlock": {
|
|
||||||
"keinekonfiguration": "Keine Konfiguration"
|
|
||||||
},
|
|
||||||
"entityHistoryPanel": {
|
|
||||||
"loadinghistory": "Loading history"
|
|
||||||
},
|
|
||||||
"printButton": {
|
|
||||||
"druckenoderalspdfexportieren": "Drucken oder als PDF exportieren",
|
|
||||||
"druckenpdf": "Drucken / PDF",
|
|
||||||
"alspdf": "Als PDF"
|
|
||||||
},
|
|
||||||
"saveFilterDialog": {
|
|
||||||
"keineaktivenfilterkriterien": "Keine aktiven Filterkriterien"
|
|
||||||
},
|
|
||||||
"shareDialog": {
|
|
||||||
"schließen": "Schließen",
|
|
||||||
"elementteilen": "Element teilen",
|
|
||||||
"gewährebenutzernodergruppenzugriffauf": "Gewähre Benutzern oder Gruppen Zugriff auf dieses Element. Die Berechtigungsstufe bestimmt,\n welche Aktionen durchgeführt werden können.",
|
|
||||||
"nochkeineberechtigungenvergebendiesesele": "Noch keine Berechtigungen vergeben. Dieses Element ist nur für den Besitzer sichtbar.",
|
|
||||||
"ablaufdatumsetzen": "Ablaufdatum setzen",
|
|
||||||
"entfernen": "Entfernen",
|
|
||||||
"berechtigungentfernen": "Berechtigung entfernen",
|
|
||||||
"neueberechtigung": "Neue Berechtigung",
|
|
||||||
"ablaufdatumoptional": "Ablaufdatum (optional)",
|
|
||||||
"berechtigunghinzufügen": "Berechtigung hinzufügen"
|
|
||||||
},
|
|
||||||
"contactEditForm": {
|
|
||||||
"techcorpgmbh": "TechCorp GmbH",
|
|
||||||
"k00123": "K-00123",
|
|
||||||
"tag1tag2": "tag1, tag2"
|
|
||||||
},
|
|
||||||
"contactFolderTree": {
|
|
||||||
"optionen": "Optionen",
|
|
||||||
"mehrereordnerauswählen": "Mehrere Ordner auswählen",
|
|
||||||
"neuerordner": "Neuer Ordner",
|
|
||||||
"keineordnervorhanden": "Keine Ordner vorhanden",
|
|
||||||
"farbewählen": "Farbe wählen"
|
|
||||||
},
|
|
||||||
"contactList": {
|
|
||||||
"ordnerzuweisen": "Ordner zuweisen ▾",
|
|
||||||
"keineordner": "Keine Ordner",
|
|
||||||
"tagshinzufügen": "Tags hinzufügen ▾",
|
|
||||||
"tag1tag2": "tag1, tag2, ...",
|
|
||||||
"auswahlaufheben": "Auswahl aufheben",
|
|
||||||
"löschenbestätigen": "Löschen bestätigen",
|
|
||||||
"kontaktewirklichlöschen": "Kontakt(e) wirklich löschen?",
|
|
||||||
"customsortierungaktivdraganddrop": "Custom Sortierung aktiv — Drag-and-Drop zum Umsortieren",
|
|
||||||
"spaltenverwalten": "Spalten verwalten"
|
|
||||||
},
|
|
||||||
"filterPanel": {
|
|
||||||
"filter": "Filter",
|
|
||||||
"allelöschen": "Alle löschen",
|
|
||||||
"bedingungenverknüpfen": "Bedingungen verknüpfen:",
|
|
||||||
"gespeichertefilter": "Gespeicherte Filter",
|
|
||||||
"keinefilteraktivklickeuntenum": "Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.",
|
|
||||||
"wählen": "— wählen —",
|
|
||||||
"wert": "Wert…",
|
|
||||||
"bedingunghinzufügen": "Bedingung hinzufügen",
|
|
||||||
"filterspeichern": "Filter speichern"
|
|
||||||
},
|
|
||||||
"folderPermissionDialog": {
|
|
||||||
"schließen": "Schließen",
|
|
||||||
"ordnerteilen": "Ordner teilen",
|
|
||||||
"gewährebenutzernodergruppenzugriffauf": "Gewähre Benutzern oder Gruppen Zugriff auf diesen Ordner. Mit „Vererben\" gelten die Rechte auch für alle Unterordner.",
|
|
||||||
"nochkeineberechtigungenvergebendieserord": "Noch keine Berechtigungen vergeben. Dieser Ordner ist nur für den Besitzer sichtbar.",
|
|
||||||
"entfernen": "Entfernen",
|
|
||||||
"berechtigungentfernen": "Berechtigung entfernen",
|
|
||||||
"neueberechtigung": "Neue Berechtigung",
|
|
||||||
"aufunterordnervererben": "Auf Unterordner vererben",
|
|
||||||
"berechtigunghinzufügen": "Berechtigung hinzufügen"
|
|
||||||
},
|
|
||||||
"groupPanel": {
|
|
||||||
"gruppierung": "Gruppierung",
|
|
||||||
"allelöschen": "Alle löschen",
|
|
||||||
"keinegruppierungaktivalledatensätzewerde": "Keine Gruppierung aktiv. Alle Datensätze werden in einer flachen Liste angezeigt.",
|
|
||||||
"gruppierunghinzufügen": "Gruppierung hinzufügen"
|
|
||||||
},
|
|
||||||
"saveViewDialog": {
|
|
||||||
"ansichtspeichern": "Ansicht speichern",
|
|
||||||
"namederansicht": "Name der Ansicht",
|
|
||||||
"zbmeinefirmenkontakte": "z.B. Meine Firmen-Kontakte",
|
|
||||||
"wassollgespeichertwerden": "Was soll gespeichert werden?",
|
|
||||||
"nichtaktiv": "nicht aktiv"
|
|
||||||
},
|
|
||||||
"sortPanel": {
|
|
||||||
"sortieren": "Sortieren",
|
|
||||||
"allelöschen": "Alle löschen",
|
|
||||||
"keinesortierungaktivdatensätzekönnenper": "Keine Sortierung aktiv. Datensätze können per Drag-and-Drop umsortiert werden.",
|
|
||||||
"sortierunghinzufügen": "Sortierung hinzufügen"
|
|
||||||
},
|
|
||||||
"customFieldRenderer": {
|
|
||||||
"required": "required",
|
|
||||||
"keineoptionenverfügbar": "Keine Optionen verfügbar",
|
|
||||||
"alleoptionenausgewählt": "Alle Optionen ausgewählt",
|
|
||||||
"bittewählen": "— Bitte wählen —"
|
|
||||||
},
|
|
||||||
"aISidebar": {
|
|
||||||
"chatsidebarcomingsoon": "Chat-Sidebar - Coming Soon",
|
|
||||||
"keinebenachrichtigungen": "Keine Benachrichtigungen",
|
|
||||||
"benachrichtigunglöschen": "Benachrichtigung löschen",
|
|
||||||
"dervollekichatistauf": "Der volle KI-Chat ist auf der AI-Assistant-Seite verfügbar.",
|
|
||||||
"aiassistantöffnen": "AI Assistant öffnen",
|
|
||||||
"einklappen": "Einklappen",
|
|
||||||
"kiassistenteinklappen": "KI Assistent einklappen",
|
|
||||||
"zurück": "Zurück"
|
|
||||||
},
|
|
||||||
"messageSidebar": {
|
|
||||||
"keinenachrichten": "Keine Nachrichten",
|
|
||||||
"senden": "Senden",
|
|
||||||
"einklappen": "Einklappen",
|
|
||||||
"messagingeinklappen": "Messaging einklappen",
|
|
||||||
"keinekonversationen": "Keine Konversationen",
|
|
||||||
"systemkonversationschreibgeschützt": "System-Konversation (schreibgeschützt)",
|
|
||||||
"zurück": "Zurück"
|
|
||||||
},
|
|
||||||
"pluginToolbar": {
|
|
||||||
"sucheschließen": "Suche schließen"
|
|
||||||
},
|
|
||||||
"sidebar": {
|
|
||||||
"seitenleistenavigation": "Seitenleiste Navigation",
|
|
||||||
"zurückzurstartseite": "Zurück zur Startseite",
|
|
||||||
"hauptnavigation": "Hauptnavigation"
|
|
||||||
},
|
|
||||||
"sortableMenuItem": {
|
|
||||||
"ziehenzumsortieren": "Ziehen zum Sortieren"
|
|
||||||
},
|
|
||||||
"topBar": {
|
|
||||||
"seitenleisteeinausklappen": "Seitenleiste ein-/ausklappen"
|
|
||||||
},
|
|
||||||
"composeModal": {
|
|
||||||
"recipientexamplecom": "recipient@example.com",
|
|
||||||
"ccexamplecom": "cc@example.com",
|
|
||||||
"bccexamplecom": "bcc@example.com",
|
|
||||||
"max25mbperfile": "Max 25 MB per file"
|
|
||||||
},
|
|
||||||
"mailComposeForm": {
|
|
||||||
"recipientexamplecom": "recipient@example.com",
|
|
||||||
"ccexamplecom": "cc@example.com",
|
|
||||||
"bccexamplecom": "bcc@example.com",
|
|
||||||
"max25mbperfile": "Max 25 MB per file"
|
|
||||||
},
|
|
||||||
"mailFilterPanel": {
|
|
||||||
"filter": "Filter",
|
|
||||||
"allelöschen": "Alle löschen",
|
|
||||||
"bedingungenverknüpfen": "Bedingungen verknüpfen:",
|
|
||||||
"gespeichertefilter": "Gespeicherte Filter",
|
|
||||||
"keinefilteraktivklickeuntenum": "Keine Filter aktiv. Klicke unten um eine Bedingung hinzuzufügen.",
|
|
||||||
"wählen": "— wählen —",
|
|
||||||
"wert": "Wert…",
|
|
||||||
"bedingunghinzufügen": "Bedingung hinzufügen",
|
|
||||||
"filterspeichern": "Filter speichern"
|
|
||||||
},
|
|
||||||
"mailFolderTree": {
|
|
||||||
"ordnerleeren": "Ordner leeren"
|
|
||||||
},
|
|
||||||
"mailGroupPanel": {
|
|
||||||
"gruppierung": "Gruppierung",
|
|
||||||
"allelöschen": "Alle löschen",
|
|
||||||
"keinegruppierungaktivklickeuntenum": "Keine Gruppierung aktiv. Klicke unten um ein Feld hinzuzufügen.",
|
|
||||||
"feldhinzufügen": "Feld hinzufügen"
|
|
||||||
},
|
|
||||||
"mailSortPanel": {
|
|
||||||
"sortieren": "Sortieren",
|
|
||||||
"allelöschen": "Alle löschen",
|
|
||||||
"keinesortierungaktivklickeuntenum": "Keine Sortierung aktiv. Klicke unten um ein Feld hinzuzufügen.",
|
|
||||||
"feldhinzufügen": "Feld hinzufügen"
|
|
||||||
},
|
|
||||||
"pgpSettings": {
|
|
||||||
"beginpgpprivatekeyblock": "-----BEGIN PGP PRIVATE KEY BLOCK-----",
|
|
||||||
"keyid": "Key ID:",
|
|
||||||
"contactuuid": "contact-uuid",
|
|
||||||
"beginpgppublickeyblock": "-----BEGIN PGP PUBLIC KEY BLOCK-----"
|
|
||||||
},
|
|
||||||
"richTextEditor": {
|
|
||||||
"textcolor": "Text color"
|
|
||||||
},
|
|
||||||
"signatureManager": {
|
|
||||||
"pmitfreundlichengrüßenbruser": "<p>Mit freundlichen Grüßen,<br>{{user.name}}</p>"
|
|
||||||
},
|
|
||||||
"notificationItem": {
|
|
||||||
"ungelesen": "ungelesen"
|
|
||||||
},
|
|
||||||
"pluginRouteRenderer": {
|
|
||||||
"loading": "Loading",
|
|
||||||
"pagenotfound": "Page Not Found",
|
|
||||||
"thepage": "The page",
|
|
||||||
"wasnotfound": "was not found."
|
|
||||||
},
|
|
||||||
"workspaceManager": {
|
|
||||||
"name": "Name",
|
|
||||||
"beschreibung": "Beschreibung",
|
|
||||||
"alsstandardworkspace": "Als Standard-Workspace",
|
|
||||||
"konfigurationbearbeiten": "Konfiguration bearbeiten",
|
|
||||||
"visiblefolderids": "{\"visible_folder_ids\": []}",
|
|
||||||
"jsonkonfigurationfürdiesesmodulz": "JSON-Konfiguration für dieses Modul (z.B. sichtbare Ordner-IDs)",
|
|
||||||
"module": "Module",
|
|
||||||
"bearbeiten": "Bearbeiten",
|
|
||||||
"löschen": "Löschen"
|
|
||||||
},
|
|
||||||
"activityFeed": {
|
|
||||||
"keineaktivitätenvorhanden": "Keine Aktivitäten vorhanden."
|
|
||||||
},
|
|
||||||
"dataGrid": {
|
|
||||||
"datagrid": "Data grid"
|
|
||||||
},
|
|
||||||
"searchDropdown": {
|
|
||||||
"alleergebnisseanzeigen": "Alle Ergebnisse anzeigen →"
|
|
||||||
},
|
|
||||||
"teamPanel": {
|
|
||||||
"keinemitarbeiter": "Keine Mitarbeiter",
|
|
||||||
"offline": "offline",
|
|
||||||
"keinegruppen": "Keine Gruppen"
|
|
||||||
},
|
|
||||||
"taskDetail": {
|
|
||||||
"uuid": "UUID"
|
|
||||||
},
|
|
||||||
"modal": {
|
|
||||||
"closedialog": "Close dialog"
|
|
||||||
},
|
|
||||||
"skeleton": {
|
|
||||||
"wirdgeladen": "Wird geladen"
|
|
||||||
},
|
|
||||||
"aiChatPanel": {
|
|
||||||
"kiassistent": "KI Assistent",
|
|
||||||
"verbindemitki": "Verbinde mit KI...",
|
|
||||||
"stelleeinefragezumaktuellenfenster": "Stelle eine Frage zum aktuellen Fenster...",
|
|
||||||
"nachrichteingeben": "Nachricht eingeben...",
|
|
||||||
"senden": "Senden"
|
|
||||||
},
|
|
||||||
"window": {
|
|
||||||
"kichateinaus": "KI Chat ein/aus",
|
|
||||||
"kichat": "KI Chat",
|
|
||||||
"minimieren": "Minimieren",
|
|
||||||
"schließen": "Schließen"
|
|
||||||
},
|
|
||||||
"stepConfigPanel": {
|
|
||||||
"zb3600": "z.B. 3600",
|
|
||||||
"20260818t090000z": "2026-08-18T09:00:00Z",
|
|
||||||
"httpsapiexamplecomwebhook": "https://api.example.com/webhook",
|
|
||||||
"keyvalue": "{\"key\": \"value\"}",
|
|
||||||
"empfaengerexamplecom": "empfaenger@example.com",
|
|
||||||
"betreff": "Betreff",
|
|
||||||
"nachrichtentext": "Nachrichtentext",
|
|
||||||
"standardkontowennleer": "Standard-Konto wenn leer",
|
|
||||||
"eventtitel": "Event-Titel",
|
|
||||||
"20260818t100000z": "2026-08-18T10:00:00Z",
|
|
||||||
"eventuuid": "Event-UUID",
|
|
||||||
"suchbegriff": "Suchbegriff",
|
|
||||||
"dateiuuid": "Datei-UUID",
|
|
||||||
"dateipdf": "datei.pdf",
|
|
||||||
"dateiinhalt": "Dateiinhalt",
|
|
||||||
"contactcompanyfile": "contact, company, file, ...",
|
|
||||||
"agentuuid": "Agent-UUID",
|
|
||||||
"aufabschlusswarten": "Auf Abschluss warten",
|
|
||||||
"entityuuid": "Entity-UUID",
|
|
||||||
"konfigurationjson": "Konfiguration (JSON)",
|
|
||||||
"zbgenehmigungeinholen": "z.B. Genehmigung einholen",
|
|
||||||
"optionalebeschreibung": "Optionale Beschreibung"
|
|
||||||
},
|
|
||||||
"workflowEditor": {
|
|
||||||
"jsonexpert": "JSON Expert",
|
|
||||||
"zbdealgenehmigungsprozess": "z.B. Deal-Genehmigungsprozess",
|
|
||||||
"optionalebeschreibung": "Optionale Beschreibung",
|
|
||||||
"schritthinzufuegen": "Schritt hinzufuegen",
|
|
||||||
"keineschrittedefiniertklickeaufschritt": "Keine Schritte definiert. Klicke auf Schritt hinzufuegen.",
|
|
||||||
"nachoben": "Nach oben",
|
|
||||||
"nachunten": "Nach unten",
|
|
||||||
"schrittentfernen": "Schritt entfernen",
|
|
||||||
"workflowjson": "Workflow (JSON)",
|
|
||||||
"namesteps": "{\"name\": \"...\", \"steps\": [...]}"
|
|
||||||
},
|
|
||||||
"workflowInstanceList": {
|
|
||||||
"fehlerbeimladenderinstanzen": "Fehler beim Laden der Instanzen",
|
|
||||||
"erneutversuchen": "Erneut versuchen",
|
|
||||||
"keineworkflowinstanzen": "Keine Workflow-Instanzen"
|
|
||||||
},
|
|
||||||
"aISettings": {
|
|
||||||
"azureopenai": "Azure OpenAI",
|
|
||||||
"modellepresets": "Modelle & Presets",
|
|
||||||
"presetname": "Preset Name",
|
|
||||||
"modellidzbgpt4o": "Modell ID (z.B. gpt-4o-mini)",
|
|
||||||
"anbieterwählen": "Anbieter wählen...",
|
|
||||||
"temperature": "Temperature",
|
|
||||||
"maxtokens": "Max Tokens",
|
|
||||||
"topp": "Top P",
|
|
||||||
"systempromptoptional": "System Prompt (optional)",
|
|
||||||
"temp": "· temp=",
|
|
||||||
"maxtokens2": "· max_tokens=",
|
|
||||||
"beschreibung": "Beschreibung",
|
|
||||||
"presetwählen": "Preset wählen...",
|
|
||||||
"systemprompt": "System Prompt",
|
|
||||||
"verfügbaretools": "Verfügbare Tools",
|
|
||||||
"diesetoolswerdenvonpluginsbereitgestellt": "Diese Tools werden von Plugins bereitgestellt und können Agenten zugewiesen werden.",
|
|
||||||
"keinetoolsverfügbarpluginskönnentools": "Keine Tools verfügbar. Plugins können Tools registrieren.",
|
|
||||||
"kiassistenteinstellungen": "KI Assistent Einstellungen"
|
|
||||||
},
|
|
||||||
"agentDashboard": {
|
|
||||||
"myagent": "My Agent",
|
|
||||||
"optionaldescription": "Optional description",
|
|
||||||
"gpt4": "gpt-4",
|
|
||||||
"youareahelpfulassistant": "You are a helpful assistant..."
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"zurückzurstartseite": "Zurück zur Startseite",
|
|
||||||
"agentennavigation": "Agenten Navigation"
|
|
||||||
},
|
|
||||||
"automation": {
|
|
||||||
"zurückzurstartseite": "Zurück zur Startseite",
|
|
||||||
"automationnavigation": "Automation Navigation"
|
|
||||||
},
|
|
||||||
"automationDashboard": {
|
|
||||||
"myautomation": "My Automation",
|
|
||||||
"optionaldescription": "Optional description",
|
|
||||||
"contactcreated": "contact.created",
|
|
||||||
"cronexpressioneg09": "Cron expression (e.g. 0 9 * * * for daily at 9am)",
|
|
||||||
"field": "Field",
|
|
||||||
"value": "Value",
|
|
||||||
"removecondition": "Remove condition",
|
|
||||||
"url": "{\"url\": \"...\"}",
|
|
||||||
"removeaction": "Remove action"
|
|
||||||
},
|
|
||||||
"automationSettings": {
|
|
||||||
"gpt4": "gpt-4",
|
|
||||||
"mycustomapp": "my_custom_app",
|
|
||||||
"mycustomapp2": "My Custom App",
|
|
||||||
"appwindow": "AppWindow",
|
|
||||||
"optionaldescription": "Optional description",
|
|
||||||
"typeformfields": "{\"type\": \"form\", \"fields\": []}"
|
|
||||||
},
|
|
||||||
"communication": {
|
|
||||||
"keinekonversationen": "Keine Konversationen",
|
|
||||||
"miniapps": "Mini-Apps",
|
|
||||||
"inneuemfenster": "In neuem Fenster",
|
|
||||||
"keinenachrichtenschreibedieerste": "Keine Nachrichten. Schreibe die erste!",
|
|
||||||
"kiantwortet": "KI antwortet...",
|
|
||||||
"dateianhängen": "Datei anhängen",
|
|
||||||
"emoji": "Emoji",
|
|
||||||
"teilnehmerauswählen": "Teilnehmer auswählen",
|
|
||||||
"wähleeinekonversationaus": "Wähle eine Konversation aus"
|
|
||||||
},
|
|
||||||
"contactsList": {
|
|
||||||
"keinebenutzerdefiniertenansichten": "Keine benutzerdefinierten Ansichten",
|
|
||||||
"aktuelleansichtspeichern": "Aktuelle Ansicht speichern",
|
|
||||||
"gespeichertefilter": "Gespeicherte Filter"
|
|
||||||
},
|
|
||||||
"customFields": {
|
|
||||||
"zbbrancheabteilunggeburtsdatum": "z.B. Branche, Abteilung, Geburtsdatum",
|
|
||||||
"zbbrancheabteilunggeburtsdatum2": "z.B. branche, abteilung, geburtsdatum",
|
|
||||||
"option1option2option3": "Option 1, Option 2, Option 3"
|
|
||||||
},
|
|
||||||
"guestContacts": {
|
|
||||||
"weiterleitungzukontakten": "Weiterleitung zu Kontakten..."
|
|
||||||
},
|
|
||||||
"guestLogin": {
|
|
||||||
"weiterleitungzumlogin": "Weiterleitung zum Login..."
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"zurückzurstartseite": "Zurück zur Startseite",
|
|
||||||
"hilfenavigation": "Hilfe Navigation"
|
|
||||||
},
|
|
||||||
"importExport": {
|
|
||||||
"tabs": "Tabs"
|
|
||||||
},
|
|
||||||
"logs": {
|
|
||||||
"zurückzurstartseite": "Zurück zur Startseite",
|
|
||||||
"logsnavigation": "Logs Navigation"
|
|
||||||
},
|
|
||||||
"mailSettings": {
|
|
||||||
"userexamplecom": "user@example.com",
|
|
||||||
"johndoe": "John Doe",
|
|
||||||
"leerlassenfüremailadresse": "Leer lassen für E-Mail-Adresse",
|
|
||||||
"imapexamplecom": "imap.example.com",
|
|
||||||
"smtpexamplecom": "smtp.example.com",
|
|
||||||
"geteiltespostfachfüralletenantbenutzer": "Geteiltes Postfach (für alle Tenant-Benutzer sichtbar)",
|
|
||||||
"ordnerzuordnungbearbeiten": "Ordner-Zuordnung bearbeiten"
|
|
||||||
},
|
|
||||||
"noAccessPage": {
|
|
||||||
"keinzugriff": "Kein Zugriff",
|
|
||||||
"siehabenkeineberechtigungaufdiese": "Sie haben keine Berechtigung, auf diese Seite zuzugreifen.\n Bitte wenden Sie sich an einen Administrator, falls Sie Zugriff benötigen.",
|
|
||||||
"zumdashboard": "Zum Dashboard"
|
|
||||||
},
|
|
||||||
"settingsBackup": {
|
|
||||||
"restore": "RESTORE"
|
|
||||||
},
|
|
||||||
"settingsMcp": {
|
|
||||||
"select": "-- Select --"
|
|
||||||
},
|
|
||||||
"settingsPlugins": {
|
|
||||||
"httpsexamplecompluginzip": "https://example.com/plugin.zip"
|
|
||||||
},
|
|
||||||
"settingsRechte": {
|
|
||||||
"löschen": "Löschen",
|
|
||||||
"freigabenübersicht": "Freigaben Übersicht",
|
|
||||||
"nameoderid": "Name oder ID...",
|
|
||||||
"berechtigunglöschen": "Berechtigung löschen",
|
|
||||||
"auditlogfürberechtigungen": "Audit-Log für Berechtigungen",
|
|
||||||
"rechteverwaltungtabs": "Rechteverwaltung Tabs"
|
|
||||||
},
|
|
||||||
"settingsSequences": {
|
|
||||||
"re": "RE-"
|
|
||||||
},
|
|
||||||
"settingsStammdaten": {
|
|
||||||
"bearbeiten": "Bearbeiten",
|
|
||||||
"löschen": "Löschen",
|
|
||||||
"adressverwaltung": "Adressverwaltung",
|
|
||||||
"ladeadressen": "Lade Adressen...",
|
|
||||||
"bankkontenverwaltung": "Bankkontenverwaltung",
|
|
||||||
"ladekonten": "Lade Konten...",
|
|
||||||
"stammdatentabs": "Stammdaten Tabs"
|
|
||||||
},
|
|
||||||
"settingsSystem": {
|
|
||||||
"systemtabs": "System Tabs"
|
|
||||||
},
|
|
||||||
"settingsTaxes": {
|
|
||||||
"de": "DE"
|
|
||||||
},
|
|
||||||
"settingsTheme": {
|
|
||||||
"2563eb": "#2563eb",
|
|
||||||
"d946ef": "#d946ef",
|
|
||||||
"primarybutton": "Primary Button",
|
|
||||||
"secondarybutton": "Secondary Button",
|
|
||||||
"dangerbutton": "Danger Button",
|
|
||||||
"ghostbutton": "Ghost Button",
|
|
||||||
"texteingeben": "Text eingeben...",
|
|
||||||
"diesisteinebeispielkartemit": "Dies ist eine Beispiel-Karte mit dem aktuellen Theme."
|
|
||||||
},
|
|
||||||
"settingsUserManagement": {
|
|
||||||
"nutzerverwaltungtabs": "Nutzerverwaltung Tabs"
|
|
||||||
},
|
|
||||||
"settingsUsers": {
|
|
||||||
"neumitarbeiterfirmade": "neu.mitarbeiter@firma.de"
|
|
||||||
},
|
|
||||||
"startPage": {
|
|
||||||
"zurückzurstartseite": "Zurück zur Startseite",
|
|
||||||
"wähleeinenworkspaceaus": "Wähle einen Workspace aus",
|
|
||||||
"workspacehinzufügen": "Workspace hinzufügen"
|
|
||||||
},
|
|
||||||
"workflows": {
|
|
||||||
"definierenundverwaltensieautomatisiertew": "Definieren und verwalten Sie automatisierte Workflows",
|
|
||||||
"fehlerbeimladenderworkflows": "Fehler beim Laden der Workflows",
|
|
||||||
"erneutversuchen": "Erneut versuchen",
|
|
||||||
"keineworkflows": "Keine Workflows",
|
|
||||||
"workflowerstellen": "Workflow erstellen",
|
|
||||||
"bearbeiten": "Bearbeiten",
|
|
||||||
"loeschen": "Loeschen",
|
|
||||||
"workflowloeschen": "Workflow loeschen"
|
|
||||||
},
|
|
||||||
"agentsOverview": {
|
|
||||||
"agentenübersicht": "Agenten Übersicht",
|
|
||||||
"verwaltensiekiagentenführensie": "Verwalten Sie KI-Agenten, führen Sie diese aus und überwachen Sie deren Ausführungen."
|
|
||||||
},
|
|
||||||
"agentsPlaceholder": {
|
|
||||||
"dieseseitewirdgeradeerstelltwählen": "Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links."
|
|
||||||
},
|
|
||||||
"automationOverview": {
|
|
||||||
"automationübersicht": "Automation Übersicht",
|
|
||||||
"erstellenundverwaltensieautomatisiertewo": "Erstellen und verwalten Sie automatisierte Workflows. Definieren Sie Trigger, Bedingungen und Aktionen.",
|
|
||||||
"workflowserstellenundverwalten": "Workflows erstellen und verwalten",
|
|
||||||
"triggeraktionenundbedingungenkonfigurier": "Trigger, Aktionen und Bedingungen konfigurieren"
|
|
||||||
},
|
|
||||||
"automationPlaceholder": {
|
|
||||||
"dieseseitewirdgeradeerstelltwählen": "Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links."
|
|
||||||
},
|
|
||||||
"helpApiDocs": {
|
|
||||||
"apidokumentation": "API Dokumentation",
|
|
||||||
"dievollständigeapidokumentationfindensie": "Die vollständige API-Dokumentation finden Sie unter",
|
|
||||||
"swaggerui": "(Swagger UI).",
|
|
||||||
"leocrmbieteteinerestapimit": "LeoCRM bietet eine REST-API mit über 224 Endpoints. Die API verwendet JSON für Request- und Response-Bodies.",
|
|
||||||
"dieapiverwendetsessionbasierteauthentifi": "Die API verwendet Session-basierte Authentifizierung mit HttpOnly-Cookies. Nach dem Login über",
|
|
||||||
"postapiv1authlogin": "POST /api/v1/auth/login",
|
|
||||||
"wirdeinsessioncookiegesetzt": "wird ein Session-Cookie gesetzt.",
|
|
||||||
"wichtigeendpoints": "Wichtige Endpoints",
|
|
||||||
"getapiv1contacts": "GET /api/v1/contacts",
|
|
||||||
"kontakteabrufen": "— Kontakte abrufen",
|
|
||||||
"postapiv1contacts": "POST /api/v1/contacts",
|
|
||||||
"kontakterstellen": "— Kontakt erstellen",
|
|
||||||
"getapiv1calendarentries": "GET /api/v1/calendar/entries",
|
|
||||||
"kalendereinträge": "— Kalendereinträge",
|
|
||||||
"getapiv1mailaccounts": "GET /api/v1/mail/accounts",
|
|
||||||
"mailkonten": "— Mail-Konten",
|
|
||||||
"getapiv1pluginsactivemanifests": "GET /api/v1/plugins/active-manifests",
|
|
||||||
"pluginmanifeste": "— Plugin-Manifeste",
|
|
||||||
"vollständigedoku": "Vollständige Doku:",
|
|
||||||
"swaggeruiöffnen": "Swagger UI öffnen"
|
|
||||||
},
|
|
||||||
"helpContacts": {
|
|
||||||
"kontakteverwalten": "Kontakte verwalten",
|
|
||||||
"kontakteerstellen": "Kontakte erstellen",
|
|
||||||
"gehensiezukontakteundklicken": "Gehen Sie zu Kontakte und klicken Sie auf \"Neuer Kontakt\". Füllen Sie die Felder aus und speichern Sie. Sie können Firmen und Personen anlegen.",
|
|
||||||
"jederfirmakönnenmehrerekontaktpersonenzu": "Jeder Firma können mehrere Kontaktpersonen zugeordnet werden. Öffnen Sie eine Firma und fügen Sie Personen hinzu.",
|
|
||||||
"verwendensietagsumkontaktezu": "Verwenden Sie Tags um Kontakte zu kategorisieren. Tags können frei vergeben werden und helfen bei der Filterung.",
|
|
||||||
"organisierensiekontakteinordnernordner": "Organisieren Sie Kontakte in Ordnern. Ordner können verschachtelt werden und eigene Berechtigungen haben."
|
|
||||||
},
|
|
||||||
"helpLogin": {
|
|
||||||
"loginanmeldung": "Login & Anmeldung",
|
|
||||||
"rufensiedieleocrmurlauf": "Rufen Sie die LeoCRM-URL auf (z.B. https://crm.media-on.de) und melden Sie sich mit Ihrer E-Mail-Adresse und Ihrem Passwort an.",
|
|
||||||
"passwortvergessen": "Passwort vergessen?",
|
|
||||||
"klickensieaufderloginseite": "Klicken Sie auf der Login-Seite auf \"Passwort vergessen\". Sie erhalten eine E-Mail mit einem Link zum Zurücksetzen Ihres Passworts.",
|
|
||||||
"ihresitzungwirdübereinsicheres": "Ihre Sitzung wird über ein sicheres HttpOnly-Cookie verwaltet",
|
|
||||||
"nachinaktivitätwirddiesitzungautomatisch": "Nach Inaktivität wird die Sitzung automatisch beendet",
|
|
||||||
"passwörterwerdenmitbcryptcost12": "Passwörter werden mit bcrypt (cost=12) verschlüsselt gespeichert"
|
|
||||||
},
|
|
||||||
"helpMailSetup": {
|
|
||||||
"postfacheinrichten": "Postfach einrichten",
|
|
||||||
"imapkontohinzufügen": "IMAP-Konto hinzufügen",
|
|
||||||
"gehensiezueinstellungenemailund": "Gehen Sie zu Einstellungen → Email und klicken Sie auf \"Konto hinzufügen\". Geben Sie Ihre IMAP- und SMTP-Serverdaten ein.",
|
|
||||||
"benötigtedaten": "Benötigte Daten",
|
|
||||||
"imapserverzbimapexample": "IMAP-Server (z.B. imap.example.com)",
|
|
||||||
"imapportmeist993fürssl": "IMAP-Port (meist 993 für SSL)",
|
|
||||||
"smtpserverzbsmtpexample": "SMTP-Server (z.B. smtp.example.com)",
|
|
||||||
"smtpportmeist587fürtls": "SMTP-Port (meist 587 für TLS)",
|
|
||||||
"emailadresseundpasswort": "E-Mail-Adresse und Passwort",
|
|
||||||
"nachdemeinrichtenwirdihrpostfach": "Nach dem Einrichten wird Ihr Postfach automatisch synchronisiert. Neue E-Mails werden im Hintergrund abgerufen."
|
|
||||||
},
|
|
||||||
"helpNavigation": {
|
|
||||||
"nachdemlogingelangensiezur": "Nach dem Login gelangen Sie zur Startseite. Hier können Sie einen Workspace auswählen oder zu den Einstellungen und der Hilfe navigieren.",
|
|
||||||
"einworkspaceistihrarbeitsbereichmit": "Ein Workspace ist Ihr Arbeitsbereich mit Sidebar-Navigation. Hier finden Sie Kontakte, Kalender, E-Mail und alle anderen Module.",
|
|
||||||
"dashamburgermenüobenlinksblendet": "Das Hamburger-Menü oben links blendet die Seitenleiste ein und aus. Die Sidebar zeigt alle verfügbaren Module.",
|
|
||||||
"rechtsnebendemhamburgermenüfinden": "Rechts neben dem Hamburger-Menü finden Sie einen Zurück-Pfeil, der Sie zurück zur Startseite bringt.",
|
|
||||||
"globalesuche": "Globale Suche",
|
|
||||||
"verwendensiedielupeobenoder": "Verwenden Sie die Lupe oben oder Strg+K um das Kommando-Palette zu öffnen und schnell nach Kontakten, Mails oder Dateien zu suchen."
|
|
||||||
},
|
|
||||||
"helpPlaceholder": {
|
|
||||||
"diesehilfeseite": "Diese Hilfeseite (",
|
|
||||||
"wirdgeradeerstelltschauensiespäter": ") wird gerade erstellt. Schauen Sie später wieder vorbei.",
|
|
||||||
"wählensieeinthemaausdem": "Wählen Sie ein Thema aus dem Menü links um weitere Hilfe-Artikel zu lesen."
|
|
||||||
},
|
|
||||||
"helpWelcome": {
|
|
||||||
"willkommenbeileocrm": "Willkommen bei LeoCRM",
|
|
||||||
"leocrmisteinselbstgehostetescrm": "LeoCRM ist ein selbst-gehostetes CRM-System für kleine Vertriebsteams. Es bietet Kontakte, Kalender, E-Mail, Dateiverwaltung und mehr — alles in einer Anwendung.",
|
|
||||||
"kontakteverwalten": "Kontakte verwalten",
|
|
||||||
"firmenpersonenkontaktdatenzentralspeiche": "— Firmen, Personen, Kontaktdaten zentral speichern",
|
|
||||||
"termineaufgabenunderinnerungen": "— Termine, Aufgaben und Erinnerungen",
|
|
||||||
"imappostfächersynchronisierendirektantwo": "— IMAP-Postfächer synchronisieren, direkt antworten",
|
|
||||||
"dateiendms": "Dateien (DMS)",
|
|
||||||
"dokumentehochladenteilenundverwalten": "— Dokumente hochladen, teilen und verwalten",
|
|
||||||
"intelligentehilfebeiderarbeit": "— Intelligente Hilfe bei der Arbeit",
|
|
||||||
"automatisierteprozesse": "— Automatisierte Prozesse",
|
|
||||||
"ersteschritte": "Erste Schritte",
|
|
||||||
"meldensiesichmitihrenzugangsdaten": "Melden Sie sich mit Ihren Zugangsdaten an",
|
|
||||||
"wählensieeinenworkspaceaufder": "Wählen Sie einen Workspace auf der Startseite",
|
|
||||||
"beginnensiemitdemanlegenvon": "Beginnen Sie mit dem Anlegen von Kontakten",
|
|
||||||
"richtensieihremailpostfach": "Richten Sie Ihr E-Mail-Postfach unter Einstellungen → Email ein",
|
|
||||||
"nutzensiedieglobalesuchelupe": "Nutzen Sie die globale Suche (Lupe oben) oder das Kommando-Palette (Strg+K) um schnell zu finden was Sie brauchen."
|
|
||||||
},
|
|
||||||
"logsOverview": {
|
|
||||||
"logsübersicht": "Logs Übersicht",
|
|
||||||
"systemundauditlogseinsehenfiltern": "System- und Audit-Logs einsehen, filtern und exportieren.",
|
|
||||||
"alleänderungennachverfolgen": "Alle Änderungen nachverfolgen",
|
|
||||||
"containerworkerdatenbank": "Container, Worker, Datenbank",
|
|
||||||
"apiundpluginfehler": "API- und Plugin-Fehler"
|
|
||||||
},
|
|
||||||
"logsPlaceholder": {
|
|
||||||
"dieseseitewirdgeradeerstelltwählen": "Diese Seite wird gerade erstellt. Wählen Sie ein Thema aus dem Menü links."
|
|
||||||
},
|
|
||||||
"index": {
|
|
||||||
"seitewirdgeladen": "Seite wird geladen"
|
|
||||||
},
|
|
||||||
"compliance": {
|
|
||||||
"dsar": {
|
|
||||||
"tab": "DSGVO-Anfragen",
|
|
||||||
"description": "DSGVO-Anfrage für eine Person auslösen: Auskunft (Art. 15), Löschung (Art. 17) oder Berichtigung (Art. 16). Der Vorgang wird als Hintergrund-Job durch den Worker ausgeführt.",
|
|
||||||
"person": "Person",
|
|
||||||
"selectPerson": "-- Bitte wählen --",
|
|
||||||
"requestType": "Antragsart",
|
|
||||||
"typeAccess": "Auskunft (Art. 15)",
|
|
||||||
"typeDeletion": "Löschung (Art. 17)",
|
|
||||||
"typeRectification": "Berichtigung (Art. 16)",
|
|
||||||
"downloadExport": "Datenexport herunterladen",
|
|
||||||
"submit": "Anfrage stellen",
|
|
||||||
"confirmDeletion": "Wirklich löschen? Unwiderruflich!",
|
|
||||||
"chooseFirst": "Bitte zuerst eine Person wählen.",
|
|
||||||
"queued": "Anfrage eingereicht — Job",
|
|
||||||
"statusQueued": "(in Warteschlange). Die Bearbeitung erfolgt im Hintergrund."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function ProviderTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
if (!confirm(t('aiSettings.confirmDeleteProvider'))) return;
|
if (!confirm(t('aiSettings.confirmDeleteProvider'))) return;
|
||||||
try { await deleteProvider(id); await load(); } catch (e: unknown) { const errObj = asError(e); setError(errObj?.message || null); }
|
try { await deleteProvider(id); await load(); } catch (e: unknown) { const errObj = asError(e); setError(errObj?.message || null); }
|
||||||
};
|
};
|
||||||
@@ -61,7 +60,7 @@ function ProviderTab() {
|
|||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
<option value="anthropic">Anthropic</option>
|
<option value="anthropic">Anthropic</option>
|
||||||
<option value="ollama">Ollama</option>
|
<option value="ollama">Ollama</option>
|
||||||
<option value="azure">{t('aISettings.azureopenai')}</option>
|
<option value="azure">Azure OpenAI</option>
|
||||||
<option value="huggingface">HuggingFace</option>
|
<option value="huggingface">HuggingFace</option>
|
||||||
<option value="custom">Custom</option>
|
<option value="custom">Custom</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -135,24 +134,24 @@ function PresetTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold">{t('aISettings.modellepresets')}</h2>
|
<h2 className="text-lg font-semibold">Modelle & Presets</h2>
|
||||||
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm({ name: '', model_id: '', provider_id: '', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' }); }} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">{t('aiSettings.add')} </button>
|
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm({ name: '', model_id: '', provider_id: '', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' }); }} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">{t('aiSettings.add')} </button>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
|
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
|
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<input placeholder={t('aISettings.presetname')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
<input placeholder="Preset Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
<input placeholder={t('aISettings.modellidzbgpt4o')} value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
<input placeholder="Modell ID (z.B. gpt-4o-mini)" value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
<select value={form.provider_id} onChange={(e) => setForm({ ...form, provider_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm">
|
<select value={form.provider_id} onChange={(e) => setForm({ ...form, provider_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm">
|
||||||
<option value="">{t('aISettings.anbieterwählen')}</option>
|
<option value="">Anbieter wählen...</option>
|
||||||
{providers.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{providers.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input type="number" step="0.1" min="0" max="2" placeholder={t('aISettings.temperature')} value={form.temperature} onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
<input type="number" step="0.1" min="0" max="2" placeholder="Temperature" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
<input type="number" placeholder={t('aISettings.maxtokens')} value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: parseInt(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
<input type="number" placeholder="Max Tokens" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: parseInt(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
<input type="number" step="0.1" min="0" max="1" placeholder={t('aISettings.topp')} value={form.top_p} onChange={(e) => setForm({ ...form, top_p: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
<input type="number" step="0.1" min="0" max="1" placeholder="Top P" value={form.top_p} onChange={(e) => setForm({ ...form, top_p: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<textarea placeholder={t('aISettings.systempromptoptional')} value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={3} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
<textarea placeholder="System Prompt (optional)" value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={3} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={handleSave} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">{t('aiSettings.save')} </button>
|
<button onClick={handleSave} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">{t('aiSettings.save')} </button>
|
||||||
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 text-sm border rounded-lg">{t('aiSettings.cancel')} </button>
|
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 text-sm border rounded-lg">{t('aiSettings.cancel')} </button>
|
||||||
@@ -164,7 +163,7 @@ function PresetTab() {
|
|||||||
<div key={p.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between">
|
<div key={p.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-sm">{p.name}</div>
|
<div className="font-medium text-sm">{p.name}</div>
|
||||||
<div className="text-xs text-secondary-500">{p.model_id} {t('aISettings.temp')}{p.temperature} {t('aISettings.maxtokens2')}{p.max_tokens}</div>
|
<div className="text-xs text-secondary-500">{p.model_id} · temp={p.temperature} · max_tokens={p.max_tokens}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => handleEdit(p)} className="text-sm text-primary-600 hover:underline">{t('aiSettings.edit')} </button>
|
<button onClick={() => handleEdit(p)} className="text-sm text-primary-600 hover:underline">{t('aiSettings.edit')} </button>
|
||||||
@@ -214,7 +213,6 @@ function AgentTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleTool = (toolName: string) => {
|
const toggleTool = (toolName: string) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
tool_ids: prev.tool_ids.includes(toolName)
|
tool_ids: prev.tool_ids.includes(toolName)
|
||||||
@@ -236,13 +234,13 @@ function AgentTab() {
|
|||||||
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
|
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<input placeholder={t('aiSettings.name')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
<input placeholder={t('aiSettings.name')} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
<input placeholder={t('aISettings.beschreibung')} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
<input placeholder="Beschreibung" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
</div>
|
</div>
|
||||||
<select value={form.preset_id} onChange={(e) => setForm({ ...form, preset_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
<select value={form.preset_id} onChange={(e) => setForm({ ...form, preset_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
||||||
<option value="">{t('aISettings.presetwählen')}</option>
|
<option value="">Preset wählen...</option>
|
||||||
{presets.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.model_id})</option>)}
|
{presets.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.model_id})</option>)}
|
||||||
</select>
|
</select>
|
||||||
<textarea placeholder={t('aISettings.systemprompt')} value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={4} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
<textarea placeholder="System Prompt" value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={4} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||||
{tools.length > 0 && (
|
{tools.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium mb-2">Tools:</div>
|
<div className="text-sm font-medium mb-2">Tools:</div>
|
||||||
@@ -296,11 +294,11 @@ function ToolsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-semibold">{t('aISettings.verfügbaretools')}</h2>
|
<h2 className="text-lg font-semibold">Verfügbare Tools</h2>
|
||||||
<p className="text-sm text-secondary-500">{t('aISettings.diesetoolswerdenvonpluginsbereitgestellt')}</p>
|
<p className="text-sm text-secondary-500">Diese Tools werden von Plugins bereitgestellt und können Agenten zugewiesen werden.</p>
|
||||||
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
|
{error && <div className="text-sm text-red-600">{typeof error === "string" ? error : (error as any)?.message || "t('common.errorOccurred')"}</div>}
|
||||||
{tools.length === 0 ? (
|
{tools.length === 0 ? (
|
||||||
<div className="text-sm text-secondary-400 py-8 text-center">{t('aISettings.keinetoolsverfügbarpluginskönnentools')}</div>
|
<div className="text-sm text-secondary-400 py-8 text-center">Keine Tools verfügbar. Plugins können Tools registrieren.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{tools.map((tool) => (
|
{tools.map((tool) => (
|
||||||
@@ -324,7 +322,6 @@ function ToolsTab() {
|
|||||||
|
|
||||||
// ─── Main Settings Page ───
|
// ─── Main Settings Page ───
|
||||||
export function AISettingsPage() {
|
export function AISettingsPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'providers', label: 'Anbieter', content: <ProviderTab /> },
|
{ key: 'providers', label: 'Anbieter', content: <ProviderTab /> },
|
||||||
{ key: 'presets', label: 'Modelle & Presets', content: <PresetTab /> },
|
{ key: 'presets', label: 'Modelle & Presets', content: <PresetTab /> },
|
||||||
@@ -334,7 +331,7 @@ export function AISettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('aISettings.kiassistenteinstellungen')}</h1>
|
<h1 className="text-2xl font-bold text-secondary-900 mb-6">KI Assistent Einstellungen</h1>
|
||||||
<Tabs tabs={tabs} />
|
<Tabs tabs={tabs} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ function AgentForm({
|
|||||||
onChange={(e) => updateField('name', e.target.value)}
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('agentDashboard.myagent')}
|
placeholder="My Agent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -196,7 +196,7 @@ function AgentForm({
|
|||||||
onChange={(e) => updateField('description', e.target.value)}
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('agentDashboard.optionaldescription')}
|
placeholder="Optional description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@ function AgentForm({
|
|||||||
onChange={(e) => updateField('model', e.target.value)}
|
onChange={(e) => updateField('model', e.target.value)}
|
||||||
list="model-suggestions"
|
list="model-suggestions"
|
||||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('agentDashboard.gpt4')}
|
placeholder="gpt-4"
|
||||||
/>
|
/>
|
||||||
<datalist id="model-suggestions">
|
<datalist id="model-suggestions">
|
||||||
{commonModels.map((m) => (
|
{commonModels.map((m) => (
|
||||||
@@ -233,7 +233,7 @@ function AgentForm({
|
|||||||
onChange={(e) => updateField('system_prompt', e.target.value)}
|
onChange={(e) => updateField('system_prompt', e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('agentDashboard.youareahelpfulassistant')}
|
placeholder="You are a helpful assistant..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { useUIStore } from '@/store/uiStore';
|
||||||
import { ChevronRight, ChevronDown, Bot, Zap, Brain, Cpu, Settings2, Activity, MessageSquare, ArrowLeft } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Bot, Zap, Brain, Cpu, Settings2, Activity, MessageSquare, ArrowLeft } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface AgentNode {
|
interface AgentNode {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -24,7 +23,6 @@ const AGENT_TREE: AgentNode[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function TreeItem({ node, depth }: { node: AgentNode; depth: number }) {
|
function TreeItem({ node, depth }: { node: AgentNode; depth: number }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState(depth < 1);
|
const [expanded, setExpanded] = useState(depth < 1);
|
||||||
const hasChildren = node.children && node.children.length > 0;
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
const paddingLeft = depth * 16 + 12;
|
const paddingLeft = depth * 16 + 12;
|
||||||
@@ -72,7 +70,6 @@ function TreeItem({ node, depth }: { node: AgentNode; depth: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AgentsPage() {
|
export function AgentsPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
|
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -83,14 +80,14 @@ export function AgentsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/start'}
|
onClick={() => window.location.href = '/start'}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
||||||
aria-label={t('agents.zurückzurstartseite')}
|
aria-label="Zurück zur Startseite"
|
||||||
title={t('agents.zurückzurstartseite')}
|
title="Zurück zur Startseite"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-xl font-bold text-secondary-900">Agenten</h1>
|
<h1 className="text-xl font-bold text-secondary-900">Agenten</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('agents.agentennavigation')}>
|
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Agenten Navigation">
|
||||||
{AGENT_TREE.map((node) => (
|
{AGENT_TREE.map((node) => (
|
||||||
<TreeItem key={node.title} node={node} depth={0} />
|
<TreeItem key={node.title} node={node} depth={0} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -115,19 +115,19 @@ export function AuditLogPage() {
|
|||||||
label={t('auditLog.user')}
|
label={t('auditLog.user')}
|
||||||
value={filterUser}
|
value={filterUser}
|
||||||
onChange={(e) => setFilterUser(e.target.value)}
|
onChange={(e) => setFilterUser(e.target.value)}
|
||||||
placeholder={t('auditLog.annaschmidt')}
|
placeholder="anna.schmidt"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('auditLog.action')}
|
label={t('auditLog.action')}
|
||||||
value={filterAction}
|
value={filterAction}
|
||||||
onChange={(e) => setFilterAction(e.target.value)}
|
onChange={(e) => setFilterAction(e.target.value)}
|
||||||
placeholder={t('auditLog.createupdatedelete')}
|
placeholder="create, update, delete"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('auditLog.entity')}
|
label={t('auditLog.entity')}
|
||||||
value={filterEntity}
|
value={filterEntity}
|
||||||
onChange={(e) => setFilterEntity(e.target.value)}
|
onChange={(e) => setFilterEntity(e.target.value)}
|
||||||
placeholder={t('auditLog.companycontact')}
|
placeholder="company, contact"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('auditLog.dateFrom')}
|
label={t('auditLog.dateFrom')}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { useUIStore } from '@/store/uiStore';
|
||||||
import { ChevronRight, ChevronDown, Zap, Play, History, GitBranch, Settings2, Activity, Clock, ArrowLeft } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Zap, Play, History, GitBranch, Settings2, Activity, Clock, ArrowLeft } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface AutomationNode {
|
interface AutomationNode {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -33,7 +32,6 @@ const AUTOMATION_TREE: AutomationNode[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) {
|
function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState(depth < 1);
|
const [expanded, setExpanded] = useState(depth < 1);
|
||||||
const hasChildren = node.children && node.children.length > 0;
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
const paddingLeft = depth * 16 + 12;
|
const paddingLeft = depth * 16 + 12;
|
||||||
@@ -81,7 +79,6 @@ function TreeItem({ node, depth }: { node: AutomationNode; depth: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AutomationPage() {
|
export function AutomationPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
|
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,14 +89,14 @@ export function AutomationPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/start'}
|
onClick={() => window.location.href = '/start'}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
||||||
aria-label={t('automation.zurückzurstartseite')}
|
aria-label="Zurück zur Startseite"
|
||||||
title={t('automation.zurückzurstartseite')}
|
title="Zurück zur Startseite"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-xl font-bold text-secondary-900">Automation</h1>
|
<h1 className="text-xl font-bold text-secondary-900">Automation</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('automation.automationnavigation')}>
|
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Automation Navigation">
|
||||||
{AUTOMATION_TREE.map((node) => (
|
{AUTOMATION_TREE.map((node) => (
|
||||||
<TreeItem key={node.title} node={node} depth={0} />
|
<TreeItem key={node.title} node={node} depth={0} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function AutomationForm({
|
|||||||
onChange={(e) => updateField('name', e.target.value)}
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('automationDashboard.myautomation')}
|
placeholder="My Automation"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -227,7 +227,7 @@ function AutomationForm({
|
|||||||
onChange={(e) => updateField('description', e.target.value)}
|
onChange={(e) => updateField('description', e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('automationDashboard.optionaldescription')}
|
placeholder="Optional description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +255,7 @@ function AutomationForm({
|
|||||||
value={form.trigger_config.event_name || ''}
|
value={form.trigger_config.event_name || ''}
|
||||||
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, event_name: e.target.value })}
|
onChange={(e) => updateField('trigger_config', { ...form.trigger_config, event_name: e.target.value })}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('automationDashboard.contactcreated')}
|
placeholder="contact.created"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -271,7 +271,7 @@ function AutomationForm({
|
|||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder="0 9 * * *"
|
placeholder="0 9 * * *"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-secondary-400 mt-1">{t('automationDashboard.cronexpressioneg09')}</p>
|
<p className="text-xs text-secondary-400 mt-1">Cron expression (e.g. 0 9 * * * for daily at 9am)</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -294,7 +294,7 @@ function AutomationForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={cond.field}
|
value={cond.field}
|
||||||
onChange={(e) => updateCondition(i, 'field', e.target.value)}
|
onChange={(e) => updateCondition(i, 'field', e.target.value)}
|
||||||
placeholder={t('automationDashboard.field')}
|
placeholder="Field"
|
||||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
@@ -307,14 +307,14 @@ function AutomationForm({
|
|||||||
type="text"
|
type="text"
|
||||||
value={cond.value}
|
value={cond.value}
|
||||||
onChange={(e) => updateCondition(i, 'value', e.target.value)}
|
onChange={(e) => updateCondition(i, 'value', e.target.value)}
|
||||||
placeholder={t('automationDashboard.value')}
|
placeholder="Value"
|
||||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeCondition(i)}
|
onClick={() => removeCondition(i)}
|
||||||
className="text-danger-500 hover:text-danger-700 p-1"
|
className="text-danger-500 hover:text-danger-700 p-1"
|
||||||
aria-label={t('automationDashboard.removecondition')}
|
aria-label="Remove condition"
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -353,14 +353,14 @@ function AutomationForm({
|
|||||||
// ignore invalid JSON while typing
|
// ignore invalid JSON while typing
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={t('automationDashboard.url')}
|
placeholder='{"url": "..."}'
|
||||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeAction(i)}
|
onClick={() => removeAction(i)}
|
||||||
className="text-danger-500 hover:text-danger-700 p-1"
|
className="text-danger-500 hover:text-danger-700 p-1"
|
||||||
aria-label={t('automationDashboard.removeaction')}
|
aria-label="Remove action"
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export function AutomationSettingsPage() {
|
|||||||
value={form.default_llm_model}
|
value={form.default_llm_model}
|
||||||
onChange={(e) => updateField('default_llm_model', e.target.value)}
|
onChange={(e) => updateField('default_llm_model', e.target.value)}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
placeholder={t('automationSettings.gpt4')}
|
placeholder="gpt-4"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p>
|
<p className="text-xs text-secondary-400 mt-1">{t('automation.defaultLlmModelHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +272,7 @@ export function AutomationSettingsPage() {
|
|||||||
value={miniAppForm.app_id}
|
value={miniAppForm.app_id}
|
||||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })}
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, app_id: e.target.value })}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
placeholder={t('automationSettings.mycustomapp')}
|
placeholder="my_custom_app"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -282,7 +282,7 @@ export function AutomationSettingsPage() {
|
|||||||
value={miniAppForm.name}
|
value={miniAppForm.name}
|
||||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })}
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, name: e.target.value })}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
placeholder={t('automationSettings.mycustomapp2')}
|
placeholder="My Custom App"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -292,7 +292,7 @@ export function AutomationSettingsPage() {
|
|||||||
value={miniAppForm.icon}
|
value={miniAppForm.icon}
|
||||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })}
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, icon: e.target.value })}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
placeholder={t('automationSettings.appwindow')}
|
placeholder="AppWindow"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -302,7 +302,7 @@ export function AutomationSettingsPage() {
|
|||||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })}
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, description: e.target.value })}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm"
|
||||||
placeholder={t('automationSettings.optionaldescription')}
|
placeholder="Optional description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -312,7 +312,7 @@ export function AutomationSettingsPage() {
|
|||||||
onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })}
|
onChange={(e) => setMiniAppForm({ ...miniAppForm, render_schema: e.target.value })}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono"
|
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono"
|
||||||
placeholder={t('automationSettings.typeformfields')}
|
placeholder='{"type": "form", "fields": []}'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
|||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { streamChat, fetchAgents, fetchMessages as fetchAiMessages, type AIAgent } from '@/api/ai';
|
import { streamChat, fetchAgents, fetchMessages as fetchAiMessages, type AIAgent } from '@/api/ai';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
// ─── Types ───
|
// ─── Types ───
|
||||||
|
|
||||||
@@ -217,7 +216,6 @@ interface ConversationTreeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, loading }: ConversationTreeProps) {
|
function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, loading }: ConversationTreeProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState<Record<ConversationCategory, boolean>>({
|
const [expanded, setExpanded] = useState<Record<ConversationCategory, boolean>>({
|
||||||
system: true,
|
system: true,
|
||||||
ai: true,
|
ai: true,
|
||||||
@@ -295,7 +293,7 @@ function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, lo
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{expanded[section.category] && section.conversations.length === 0 && (
|
{expanded[section.category] && section.conversations.length === 0 && (
|
||||||
<div className="px-6 py-2 text-xs text-secondary-400">{t('communication.keinekonversationen')}</div>
|
<div className="px-6 py-2 text-xs text-secondary-400">Keine Konversationen</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -313,7 +311,6 @@ interface ChatWindowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
|
function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -502,11 +499,11 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowMiniApps(!showMiniApps)}
|
onClick={() => setShowMiniApps(!showMiniApps)}
|
||||||
className="p-1 hover:bg-secondary-100 rounded"
|
className="p-1 hover:bg-secondary-100 rounded"
|
||||||
title={t('communication.miniapps')}
|
title="Mini-Apps"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-4 h-4 text-secondary-400" />
|
<Sparkles className="w-4 h-4 text-secondary-400" />
|
||||||
</button>
|
</button>
|
||||||
<button className="p-1 hover:bg-secondary-100 rounded" title={t('communication.inneuemfenster')}>
|
<button className="p-1 hover:bg-secondary-100 rounded" title="In neuem Fenster">
|
||||||
<ExternalLink className="w-4 h-4 text-secondary-400" />
|
<ExternalLink className="w-4 h-4 text-secondary-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -540,7 +537,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
|
|||||||
{!loading && messages.length === 0 && !aiStreaming && (
|
{!loading && messages.length === 0 && !aiStreaming && (
|
||||||
<div className="text-center text-secondary-400 py-8">
|
<div className="text-center text-secondary-400 py-8">
|
||||||
<MessageSquare className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
<MessageSquare className="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-sm">{t('communication.keinenachrichtenschreibedieerste')}</p>
|
<p className="text-sm">Keine Nachrichten. Schreibe die erste!</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
@@ -552,7 +549,7 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
|
|||||||
<Bot className="w-4 h-4 text-primary-600" />
|
<Bot className="w-4 h-4 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs text-secondary-500 mb-1">{t('communication.kiantwortet')}</div>
|
<div className="text-xs text-secondary-500 mb-1">KI antwortet...</div>
|
||||||
<div className="ai-markdown max-w-none break-words">
|
<div className="ai-markdown max-w-none break-words">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent || '...'}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent || '...'}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,10 +561,10 @@ function ChatWindow({ convId, category, onBack }: ChatWindowProps) {
|
|||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="border-t border-secondary-200 p-3">
|
<div className="border-t border-secondary-200 p-3">
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<button className="p-2 hover:bg-secondary-100 rounded-lg" title={t('communication.dateianhängen')}>
|
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Datei anhängen">
|
||||||
<Paperclip className="w-4 h-4 text-secondary-400" />
|
<Paperclip className="w-4 h-4 text-secondary-400" />
|
||||||
</button>
|
</button>
|
||||||
<button className="p-2 hover:bg-secondary-100 rounded-lg" title={t('communication.emoji')}>
|
<button className="p-2 hover:bg-secondary-100 rounded-lg" title="Emoji">
|
||||||
<Smile className="w-4 h-4 text-secondary-400" />
|
<Smile className="w-4 h-4 text-secondary-400" />
|
||||||
</button>
|
</button>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -670,7 +667,6 @@ interface NewChatDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
|
function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [users, setUsers] = useState<{id: string; name: string; email: string}[]>([]);
|
const [users, setUsers] = useState<{id: string; name: string; email: string}[]>([]);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
@@ -711,7 +707,7 @@ function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
|
|||||||
/>
|
/>
|
||||||
{category === 'colleague' && (
|
{category === 'colleague' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-secondary-700 mb-1 block">{t('communication.teilnehmerauswählen')}</label>
|
<label className="text-sm font-medium text-secondary-700 mb-1 block">Teilnehmer auswählen</label>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
@@ -749,14 +745,12 @@ function NewChatDialog({ category, onClose, onCreate }: NewChatDialogProps) {
|
|||||||
// ─── Main Page ───
|
// ─── Main Page ───
|
||||||
|
|
||||||
export function CommunicationPage() {
|
export function CommunicationPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showNewChat, setShowNewChat] = useState<ConversationCategory | null>(null);
|
const [showNewChat, setShowNewChat] = useState<ConversationCategory | null>(null);
|
||||||
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
|
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
|
||||||
const registerItems = usePluginToolbarStore((s) => s.registerItems);
|
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
|
||||||
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
|
|
||||||
|
|
||||||
const loadConversations = useCallback(async () => {
|
const loadConversations = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -857,7 +851,7 @@ export function CommunicationPage() {
|
|||||||
<div className="flex items-center justify-center h-full text-secondary-400">
|
<div className="flex items-center justify-center h-full text-secondary-400">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
<p>{t('communication.wähleeinekonversationaus')}</p>
|
<p>Wähle eine Konversation aus</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,14 +13,9 @@ import {
|
|||||||
type ComplianceIncident,
|
type ComplianceIncident,
|
||||||
type RetentionPolicyEntry,
|
type RetentionPolicyEntry,
|
||||||
type IncidentCreate,
|
type IncidentCreate,
|
||||||
submitDsarRequest,
|
|
||||||
downloadDsgvoExport,
|
|
||||||
type DsarRequestResponse,
|
|
||||||
type DsarType,
|
|
||||||
} from '../api/compliance';
|
} from '../api/compliance';
|
||||||
import { useUsers } from '../api/users';
|
|
||||||
|
|
||||||
type SubTab = 'registry' | 'incidents' | 'retention' | 'dsar';
|
type SubTab = 'registry' | 'incidents' | 'retention';
|
||||||
|
|
||||||
export function ComplianceTab() {
|
export function ComplianceTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -30,7 +25,6 @@ export function ComplianceTab() {
|
|||||||
{ key: 'registry', label: t('compliance.aiRegistry', 'AI-Register') },
|
{ key: 'registry', label: t('compliance.aiRegistry', 'AI-Register') },
|
||||||
{ key: 'incidents', label: t('compliance.incidents', 'Vorfälle') },
|
{ key: 'incidents', label: t('compliance.incidents', 'Vorfälle') },
|
||||||
{ key: 'retention', label: t('compliance.retention', 'Aufbewahrung') },
|
{ key: 'retention', label: t('compliance.retention', 'Aufbewahrung') },
|
||||||
{ key: 'dsar', label: t('compliance.dsar.tab', 'DSGVO-Anfragen') },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +53,6 @@ export function ComplianceTab() {
|
|||||||
{subTab === 'registry' && <AIRegistryPanel />}
|
{subTab === 'registry' && <AIRegistryPanel />}
|
||||||
{subTab === 'incidents' && <IncidentsPanel />}
|
{subTab === 'incidents' && <IncidentsPanel />}
|
||||||
{subTab === 'retention' && <RetentionPanel />}
|
{subTab === 'retention' && <RetentionPanel />}
|
||||||
{subTab === 'dsar' && <DsarPanel />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,135 +461,3 @@ function RetentionPanel() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DSAR Panel (GDPR Art. 15/17/20) ───
|
|
||||||
|
|
||||||
const DSAR_TYPES: { value: DsarType; labelKey: string; fallback: string }[] = [
|
|
||||||
{ value: 'access', labelKey: 'compliance.dsar.typeAccess', fallback: 'Auskunft (Art. 15)' },
|
|
||||||
{ value: 'deletion', labelKey: 'compliance.dsar.typeDeletion', fallback: 'Löschung (Art. 17)' },
|
|
||||||
{ value: 'rectification', labelKey: 'compliance.dsar.typeRectification', fallback: 'Berichtigung (Art. 16)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function DsarPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [userId, setUserId] = useState('');
|
|
||||||
const [dsarType, setDsarType] = useState<DsarType>('access');
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
||||||
const [lastJob, setLastJob] = useState<DsarRequestResponse | null>(null);
|
|
||||||
const [exportError, setExportError] = useState(false);
|
|
||||||
|
|
||||||
const { data: usersData, isLoading: usersLoading } = useUsers(1, 200);
|
|
||||||
|
|
||||||
const dsarMutation = useMutation({
|
|
||||||
mutationFn: () => submitDsarRequest(userId, dsarType),
|
|
||||||
onSuccess: (resp) => {
|
|
||||||
setLastJob(resp);
|
|
||||||
setConfirmDelete(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedUser = usersData?.items.find((u) => u.id === userId);
|
|
||||||
|
|
||||||
const handleExportClick = async () => {
|
|
||||||
setExportError(false);
|
|
||||||
try {
|
|
||||||
await downloadDsgvoExport(userId, selectedUser?.name ?? undefined);
|
|
||||||
} catch {
|
|
||||||
setExportError(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!userId) return;
|
|
||||||
if (dsarType === 'deletion' && !confirmDelete) {
|
|
||||||
setConfirmDelete(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dsarMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 max-w-2xl">
|
|
||||||
<p className="text-sm text-secondary-600">{t('compliance.dsar.description', 'DSGVO-Anfrage für eine Person auslösen: Auskunft (Art. 15), Löschung (Art. 17) oder Berichtigung (Art. 16). Der Vorgang wird als Hintergrund-Job durch den Worker ausgeführt.')}</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="dsar-user-select" className="block text-xs font-medium text-secondary-500 uppercase mb-1">{t('compliance.dsar.person', 'Person')}</label>
|
|
||||||
<select
|
|
||||||
id="dsar-user-select"
|
|
||||||
value={userId}
|
|
||||||
onChange={(e) => { setUserId(e.target.value); setConfirmDelete(false); }}
|
|
||||||
className="w-full max-w-md px-3 py-3 border border-secondary-300 rounded bg-white text-sm"
|
|
||||||
aria-label={t('compliance.dsar.person', 'Person')}
|
|
||||||
>
|
|
||||||
<option value="">{usersLoading ? t('common.loading', 'Laden...') : t('compliance.dsar.selectPerson', '-- Bitte wählen --')}</option>
|
|
||||||
{(usersData?.items ?? []).map((u) => (
|
|
||||||
<option key={u.id} value={u.id}>{u.name || u.email}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<legend className="block text-xs font-medium text-secondary-500 uppercase mb-1">{t('compliance.dsar.requestType', 'Antragsart')}</legend>
|
|
||||||
<div className="space-y-1" role="radiogroup" aria-label={t('compliance.dsar.requestType', 'Antragsart')}>
|
|
||||||
{DSAR_TYPES.map(({ value, labelKey, fallback }) => (
|
|
||||||
<label
|
|
||||||
key={value}
|
|
||||||
className={`flex items-center gap-2 px-3 py-3 min-h-[44px] rounded cursor-pointer border ${
|
|
||||||
dsarType === value ? 'border-primary-500 bg-primary-50' : 'border-secondary-200 hover:bg-secondary-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="dsar-type"
|
|
||||||
checked={dsarType === value}
|
|
||||||
onChange={() => { setDsarType(value); setConfirmDelete(false); }}
|
|
||||||
aria-label={t(labelKey, fallback)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{t(labelKey, fallback)}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 items-center">
|
|
||||||
{dsarType === 'access' && (
|
|
||||||
<button
|
|
||||||
onClick={handleExportClick}
|
|
||||||
disabled={!userId}
|
|
||||||
className="px-4 py-3 min-h-[44px] text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded disabled:opacity-50"
|
|
||||||
aria-label={t('compliance.dsar.downloadExport', 'Datenexport herunterladen')}
|
|
||||||
>
|
|
||||||
{t('compliance.dsar.downloadExport', 'Datenexport herunterladen')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!userId || dsarMutation.isPending}
|
|
||||||
className={`px-4 py-3 min-h-[44px] text-sm font-medium text-white rounded disabled:opacity-50 ${
|
|
||||||
dsarType === 'deletion' ? 'bg-red-600 hover:bg-red-700' : 'bg-primary-600 hover:bg-primary-700'
|
|
||||||
}`}
|
|
||||||
aria-label={t('compliance.dsar.submit', 'Anfrage stellen')}
|
|
||||||
>
|
|
||||||
{dsarType === 'deletion' && confirmDelete
|
|
||||||
? t('compliance.dsar.confirmDeletion', 'Wirklich löschen? Unwiderruflich!')
|
|
||||||
: t('compliance.dsar.submit', 'Anfrage stellen')}
|
|
||||||
</button>
|
|
||||||
{!userId && (
|
|
||||||
<span className="text-xs text-secondary-400">{t('compliance.dsar.chooseFirst', 'Bitte zuerst eine Person wählen.')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dsarMutation.isPending && <p className="text-sm text-secondary-500" aria-live="polite">{t('common.saving', 'Wird gesendet...')}</p>}
|
|
||||||
{dsarMutation.isError && <p className="text-sm text-red-600" aria-live="polite">{t('common.errorOccurred', 'Fehler beim Senden')}</p>}
|
|
||||||
{exportError && <p className="text-sm text-red-600" aria-live="polite">{t('common.errorOccurred', 'Fehler beim Erstellen des Exports')}</p>}
|
|
||||||
|
|
||||||
{lastJob && (
|
|
||||||
<div className="rounded border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
|
|
||||||
{t('compliance.dsar.queued', 'Anfrage eingereicht — Job')}{' '}
|
|
||||||
<code className="font-mono">{lastJob.job_id}</code>{' '}
|
|
||||||
{t('compliance.dsar.statusQueued', '(in Warteschlange). Die Bearbeitung erfolgt im Hintergrund.')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -537,13 +537,13 @@ export function ContactsListPage() {
|
|||||||
{/* Placeholder for saved custom views */}
|
{/* Placeholder for saved custom views */}
|
||||||
{savedViews.length === 0 ? (
|
{savedViews.length === 0 ? (
|
||||||
<div className="px-2 py-3 text-center">
|
<div className="px-2 py-3 text-center">
|
||||||
<p className="text-[11px] text-secondary-400 mb-2">{t('contactsList.keinebenutzerdefiniertenansichten')}</p>
|
<p className="text-[11px] text-secondary-400 mb-2">Keine benutzerdefinierten Ansichten</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveView}
|
onClick={handleSaveView}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
{t('contactsList.aktuelleansichtspeichern')}
|
Aktuelle Ansicht speichern
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -572,7 +572,7 @@ export function ContactsListPage() {
|
|||||||
className="w-full flex items-center gap-1 px-2 py-1.5 rounded text-xs text-primary-600 hover:bg-primary-50 transition-colors"
|
className="w-full flex items-center gap-1 px-2 py-1.5 rounded text-xs text-primary-600 hover:bg-primary-50 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
{t('contactsList.aktuelleansichtspeichern')}
|
Aktuelle Ansicht speichern
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -756,7 +756,7 @@ export function ContactsListPage() {
|
|||||||
|
|
||||||
{/* Saved Filters Modal */}
|
{/* Saved Filters Modal */}
|
||||||
{savedFiltersOpen && (
|
{savedFiltersOpen && (
|
||||||
<Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title={t('contactsList.gespeichertefilter')}>
|
<Modal open={savedFiltersOpen} onClose={() => setSavedFiltersOpen(false)} title="Gespeicherte Filter">
|
||||||
<SavedFilters
|
<SavedFilters
|
||||||
entityType="contacts"
|
entityType="contacts"
|
||||||
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
|
currentCriteria={{ search: debouncedSearch, type: contactType, sortBy, sortOrder, folderId }}
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export function CustomFieldsPage() {
|
|||||||
required
|
required
|
||||||
value={form.label}
|
value={form.label}
|
||||||
onChange={(e) => handleLabelChange(e.target.value)}
|
onChange={(e) => handleLabelChange(e.target.value)}
|
||||||
placeholder={t('customFields.zbbrancheabteilunggeburtsdatum')}
|
placeholder="z.B. Branche, Abteilung, Geburtsdatum"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name (auto-generated) */}
|
{/* Name (auto-generated) */}
|
||||||
@@ -404,7 +404,7 @@ export function CustomFieldsPage() {
|
|||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => handleNameChange(e.target.value)}
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
helperText="Wird automatisch aus der Bezeichnung generiert"
|
helperText="Wird automatisch aus der Bezeichnung generiert"
|
||||||
placeholder={t('customFields.zbbrancheabteilunggeburtsdatum2')}
|
placeholder="z.B. branche, abteilung, geburtsdatum"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Field type */}
|
{/* Field type */}
|
||||||
@@ -423,7 +423,7 @@ export function CustomFieldsPage() {
|
|||||||
required
|
required
|
||||||
value={form.optionsStr}
|
value={form.optionsStr}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, optionsStr: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, optionsStr: e.target.value }))}
|
||||||
placeholder={t('customFields.option1option2option3')}
|
placeholder="Option 1, Option 2, Option 3"
|
||||||
helperText="Trennen Sie die Optionen mit Kommas"
|
helperText="Trennen Sie die Optionen mit Kommas"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -121,21 +121,21 @@ export function DashboardPage() {
|
|||||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<DollarSign className="w-4 h-4 text-warning-600" />
|
<DollarSign className="w-4 h-4 text-warning-600" />
|
||||||
<span className="text-sm font-medium text-secondary-900">{t('dashboard.llmcost24h')}</span>
|
<span className="text-sm font-medium text-secondary-900">LLM Cost (24h)</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-secondary-900">${systemData.llm.last_24h_cost?.toFixed(2) ?? '0.00'}</p>
|
<p className="text-2xl font-bold text-secondary-900">${systemData.llm.last_24h_cost?.toFixed(2) ?? '0.00'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<TrendingUp className="w-4 h-4 text-primary-600" />
|
<TrendingUp className="w-4 h-4 text-primary-600" />
|
||||||
<span className="text-sm font-medium text-secondary-900">{t('dashboard.llmtokens24h')}</span>
|
<span className="text-sm font-medium text-secondary-900">LLM Tokens (24h)</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-secondary-900">{systemData.llm.last_24h_tokens?.toLocaleString() ?? '0'}</p>
|
<p className="text-2xl font-bold text-secondary-900">{systemData.llm.last_24h_tokens?.toLocaleString() ?? '0'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Activity className="w-4 h-4 text-primary-600" />
|
<Activity className="w-4 h-4 text-primary-600" />
|
||||||
<span className="text-sm font-medium text-secondary-900">{t('dashboard.activeplugins')}</span>
|
<span className="text-sm font-medium text-secondary-900">Active Plugins</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-secondary-900">{systemData.plugins?.active_plugins?.length ?? '—'}</p>
|
<p className="text-2xl font-bold text-secondary-900">{systemData.plugins?.active_plugins?.length ?? '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
// Guest contacts page now redirects to normal contacts page
|
// Guest contacts page now redirects to normal contacts page
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export function GuestContactsPage() {
|
export function GuestContactsPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -15,7 +13,7 @@ export function GuestContactsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<p className="text-gray-500">{t('guestContacts.weiterleitungzukontakten')}</p>
|
<p className="text-gray-500">Weiterleitung zu Kontakten...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
// Guest login page now redirects to normal login
|
// Guest login page now redirects to normal login
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export function GuestLoginPage() {
|
export function GuestLoginPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -15,7 +13,7 @@ export function GuestLoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<p className="text-gray-500">{t('guestLogin.weiterleitungzumlogin')}</p>
|
<p className="text-gray-500">Weiterleitung zum Login...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,14 +142,14 @@ export function HelpPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/start'}
|
onClick={() => window.location.href = '/start'}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
||||||
aria-label={t('help.zurückzurstartseite')}
|
aria-label="Zurück zur Startseite"
|
||||||
title={t('help.zurückzurstartseite')}
|
title="Zurück zur Startseite"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-xl font-bold text-secondary-900">Hilfe</h1>
|
<h1 className="text-xl font-bold text-secondary-900">Hilfe</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('help.hilfenavigation')}>
|
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Hilfe Navigation">
|
||||||
{HELP_TREE.map((node) => (
|
{HELP_TREE.map((node) => (
|
||||||
<TreeItem key={node.title} node={node} depth={0} />
|
<TreeItem key={node.title} node={node} depth={0} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function ImportExportPage() {
|
|||||||
|
|
||||||
{/* Tab navigation */}
|
{/* Tab navigation */}
|
||||||
<div className="border-b border-secondary-200">
|
<div className="border-b border-secondary-200">
|
||||||
<nav className="flex gap-1" aria-label={t('importExport.tabs')}>
|
<nav className="flex gap-1" aria-label="Tabs">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { useUIStore } from '@/store/uiStore';
|
||||||
import { ChevronRight, ChevronDown, ScrollText, FileText, AlertTriangle, ArrowLeft } from 'lucide-react';
|
import { ChevronRight, ChevronDown, ScrollText, FileText, AlertTriangle, ArrowLeft } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface LogNode {
|
interface LogNode {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -41,7 +40,6 @@ const LOG_TREE: LogNode[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function TreeItem({ node, depth }: { node: LogNode; depth: number }) {
|
function TreeItem({ node, depth }: { node: LogNode; depth: number }) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expanded, setExpanded] = useState(depth < 1);
|
const [expanded, setExpanded] = useState(depth < 1);
|
||||||
const hasChildren = node.children && node.children.length > 0;
|
const hasChildren = node.children && node.children.length > 0;
|
||||||
const paddingLeft = depth * 16 + 12;
|
const paddingLeft = depth * 16 + 12;
|
||||||
@@ -89,7 +87,6 @@ function TreeItem({ node, depth }: { node: LogNode; depth: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LogsPage() {
|
export function LogsPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
|
const sidebarOpen = useUIStore((state) => state.sidebarOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,14 +97,14 @@ export function LogsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/start'}
|
onClick={() => window.location.href = '/start'}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
||||||
aria-label={t('logs.zurückzurstartseite')}
|
aria-label="Zurück zur Startseite"
|
||||||
title={t('logs.zurückzurstartseite')}
|
title="Zurück zur Startseite"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-xl font-bold text-secondary-900">Logs</h1>
|
<h1 className="text-xl font-bold text-secondary-900">Logs</h1>
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label={t('logs.logsnavigation')}>
|
<nav className="space-y-0.5 overflow-y-auto" role="navigation" aria-label="Logs Navigation">
|
||||||
{LOG_TREE.map((node) => (
|
{LOG_TREE.map((node) => (
|
||||||
<TreeItem key={node.title} node={node} depth={0} />
|
<TreeItem key={node.title} node={node} depth={0} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -970,7 +970,7 @@ export function MailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveView('folders')}
|
onClick={() => setActiveView('folders')}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
aria-label={t('mail.zurückzuordnern')}
|
aria-label="Zurück zu Ordnern"
|
||||||
data-testid="mobile-back-to-folders"
|
data-testid="mobile-back-to-folders"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
@@ -1003,7 +1003,7 @@ export function MailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActiveView('list')}
|
onClick={() => setActiveView('list')}
|
||||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||||
aria-label={t('mail.zurückzurliste')}
|
aria-label="Zurück zur Liste"
|
||||||
data-testid="mobile-back-to-list"
|
data-testid="mobile-back-to-list"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||||
|
|||||||
@@ -221,25 +221,25 @@ export function MailSettingsPage() {
|
|||||||
label={t('mail.email')}
|
label={t('mail.email')}
|
||||||
{...registerAccount('email')}
|
{...registerAccount('email')}
|
||||||
error={accountErrors.email?.message === 'required' ? t('validation.required') : accountErrors.email?.message === 'invalidEmail' ? t('validation.email') : undefined}
|
error={accountErrors.email?.message === 'required' ? t('validation.required') : accountErrors.email?.message === 'invalidEmail' ? t('validation.email') : undefined}
|
||||||
placeholder={t('mailSettings.userexamplecom')}
|
placeholder="user@example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.displayName')}
|
label={t('mail.displayName')}
|
||||||
{...registerAccount('display_name')}
|
{...registerAccount('display_name')}
|
||||||
placeholder={t('mailSettings.johndoe')}
|
placeholder="John Doe"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Benutzername (IMAP/SMTP)"
|
label="Benutzername (IMAP/SMTP)"
|
||||||
{...registerAccount('username')}
|
{...registerAccount('username')}
|
||||||
placeholder={t('mailSettings.leerlassenfüremailadresse')}
|
placeholder="Leer lassen für E-Mail-Adresse"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.imapHost')}
|
label={t('mail.imapHost')}
|
||||||
{...registerAccount('imap_host')}
|
{...registerAccount('imap_host')}
|
||||||
error={accountErrors.imap_host?.message === 'required' ? t('validation.required') : undefined}
|
error={accountErrors.imap_host?.message === 'required' ? t('validation.required') : undefined}
|
||||||
placeholder={t('mailSettings.imapexamplecom')}
|
placeholder="imap.example.com"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.imapPort')}
|
label={t('mail.imapPort')}
|
||||||
@@ -253,7 +253,7 @@ export function MailSettingsPage() {
|
|||||||
label={t('mail.smtpHost')}
|
label={t('mail.smtpHost')}
|
||||||
{...registerAccount('smtp_host')}
|
{...registerAccount('smtp_host')}
|
||||||
error={accountErrors.smtp_host?.message === 'required' ? t('validation.required') : undefined}
|
error={accountErrors.smtp_host?.message === 'required' ? t('validation.required') : undefined}
|
||||||
placeholder={t('mailSettings.smtpexamplecom')}
|
placeholder="smtp.example.com"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label={t('mail.smtpPort')}
|
label={t('mail.smtpPort')}
|
||||||
@@ -275,7 +275,7 @@ export function MailSettingsPage() {
|
|||||||
{...registerAccount('is_shared')}
|
{...registerAccount('is_shared')}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
{t('mailSettings.geteiltespostfachfüralletenantbenutzer')}
|
Geteiltes Postfach (für alle Tenant-Benutzer sichtbar)
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="submit" isLoading={accountSubmitting} size="sm">{t('common.save')}</Button>
|
<Button type="submit" isLoading={accountSubmitting} size="sm">{t('common.save')}</Button>
|
||||||
@@ -392,7 +392,7 @@ export function MailSettingsPage() {
|
|||||||
className="text-xs text-secondary-400 hover:text-secondary-600"
|
className="text-xs text-secondary-400 hover:text-secondary-600"
|
||||||
data-testid={`folder-mapping-btn-${acc.id}`}
|
data-testid={`folder-mapping-btn-${acc.id}`}
|
||||||
>
|
>
|
||||||
{t('mailSettings.ordnerzuordnungbearbeiten')}
|
Ordner-Zuordnung bearbeiten
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ShieldX } from 'lucide-react';
|
import { ShieldX } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
export function NoAccessPage() {
|
export function NoAccessPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-secondary-50 p-4">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-secondary-50 p-4">
|
||||||
<ShieldX className="w-16 h-16 text-secondary-400 mb-4" strokeWidth={1.5} />
|
<ShieldX className="w-16 h-16 text-secondary-400 mb-4" strokeWidth={1.5} />
|
||||||
<h1 className="text-2xl font-bold text-secondary-700 mb-2">{t('noAccessPage.keinzugriff')}</h1>
|
<h1 className="text-2xl font-bold text-secondary-700 mb-2">Kein Zugriff</h1>
|
||||||
<p className="text-secondary-500 mb-6 text-center max-w-md">
|
<p className="text-secondary-500 mb-6 text-center max-w-md">
|
||||||
{t('noAccessPage.siehabenkeineberechtigungaufdiese')}
|
Sie haben keine Berechtigung, auf diese Seite zuzugreifen.
|
||||||
|
Bitte wenden Sie sich an einen Administrator, falls Sie Zugriff benötigen.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
className="px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
||||||
>
|
>
|
||||||
{t('noAccessPage.zumdashboard')}
|
Zum Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ interface DownloadEntry {
|
|||||||
export function ReportsPage() {
|
export function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const registerItems = usePluginToolbarStore((s) => s.registerItems);
|
const { registerItems, unregisterPlugin } = usePluginToolbarStore();
|
||||||
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
|
|
||||||
|
|
||||||
// Data hooks
|
// Data hooks
|
||||||
const { data: templates = [], isLoading: templatesLoading } = useReportTemplates();
|
const { data: templates = [], isLoading: templatesLoading } = useReportTemplates();
|
||||||
@@ -398,7 +397,7 @@ export function ReportsPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={jsonData}
|
value={jsonData}
|
||||||
onChange={(e) => setJsonData(e.target.value)}
|
onChange={(e) => setJsonData(e.target.value)}
|
||||||
placeholder={t('reports.keyvalue')}
|
placeholder='{"key": "value"}'
|
||||||
className="w-full text-xs font-mono border border-secondary-300 rounded px-2 py-1.5 h-40 resize-none"
|
className="w-full text-xs font-mono border border-secondary-300 rounded px-2 py-1.5 h-40 resize-none"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
data-testid="textarea-json-data"
|
data-testid="textarea-json-data"
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/start'}
|
onClick={() => window.location.href = '/start'}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none"
|
||||||
aria-label={t('settings.zurückzurstartseite')}
|
aria-label="Zurück zur Startseite"
|
||||||
title={t('settings.zurückzurstartseite')}
|
title="Zurück zur Startseite"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function RestoreModal({ open, backup, onConfirm, onCancel, isRestoring }: Restor
|
|||||||
type="text"
|
type="text"
|
||||||
value={confirmText}
|
value={confirmText}
|
||||||
onChange={(e) => setConfirmText(e.target.value)}
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
placeholder={t('settingsBackup.restore')}
|
placeholder="RESTORE"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
|
'block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-danger-500 focus:border-danger-500',
|
'focus:outline-none focus:ring-2 focus:ring-danger-500 focus:border-danger-500',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function SettingsMcpPage() {
|
|||||||
onChange={(e) => setExecuteToolName(e.target.value)}
|
onChange={(e) => setExecuteToolName(e.target.value)}
|
||||||
className="border border-secondary-300 rounded px-3 py-1.5 text-sm"
|
className="border border-secondary-300 rounded px-3 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">{t('settingsMcp.select')}</option>
|
<option value="">-- Select --</option>
|
||||||
{toolsData?.tools.map((tool) => (
|
{toolsData?.tools.map((tool) => (
|
||||||
<option key={tool.name} value={tool.name}>{tool.name}</option>
|
<option key={tool.name} value={tool.name}>{tool.name}</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ function InstallPluginSection() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
placeholder={t('settingsPlugins.httpsexamplecompluginzip')}
|
placeholder="https://example.com/plugin.zip"
|
||||||
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
data-testid="plugin-url-input"
|
data-testid="plugin-url-input"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ function FreigabenTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setConfirmDelete(row)}
|
onClick={() => setConfirmDelete(row)}
|
||||||
className="p-1 rounded hover:bg-danger-50 text-danger-600"
|
className="p-1 rounded hover:bg-danger-50 text-danger-600"
|
||||||
aria-label={t('settingsRechte.löschen')}
|
aria-label="Löschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -217,7 +217,7 @@ function FreigabenTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card title={t('settingsRechte.freigabenübersicht')}>
|
<Card title="Freigaben Übersicht">
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
<div className="w-64">
|
<div className="w-64">
|
||||||
<Select
|
<Select
|
||||||
@@ -233,7 +233,7 @@ function FreigabenTab() {
|
|||||||
<div className="w-64">
|
<div className="w-64">
|
||||||
<Input
|
<Input
|
||||||
label="Nach Principal suchen"
|
label="Nach Principal suchen"
|
||||||
placeholder={t('settingsRechte.nameoderid')}
|
placeholder="Name oder ID..."
|
||||||
value={filterPrincipal}
|
value={filterPrincipal}
|
||||||
onChange={(e) => setFilterPrincipal(e.target.value)}
|
onChange={(e) => setFilterPrincipal(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -253,7 +253,7 @@ function FreigabenTab() {
|
|||||||
open={!!confirmDelete}
|
open={!!confirmDelete}
|
||||||
onCancel={() => setConfirmDelete(null)}
|
onCancel={() => setConfirmDelete(null)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title={t('settingsRechte.berechtigunglöschen')}
|
title="Berechtigung löschen"
|
||||||
message={`Soll die Berechtigung für ${entityTypeMap.get(confirmDelete.entity_type) || confirmDelete.entity_type} wirklich gelöscht werden?`}
|
message={`Soll die Berechtigung für ${entityTypeMap.get(confirmDelete.entity_type) || confirmDelete.entity_type} wirklich gelöscht werden?`}
|
||||||
confirmLabel={deleting ? 'Wird gelöscht...' : 'Löschen'}
|
confirmLabel={deleting ? 'Wird gelöscht...' : 'Löschen'}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
@@ -325,7 +325,7 @@ function AuditTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card title={t('settingsRechte.auditlogfürberechtigungen')}>
|
<Card title="Audit-Log für Berechtigungen">
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={data?.items || []}
|
data={data?.items || []}
|
||||||
@@ -384,7 +384,7 @@ export function SettingsRechtePage() {
|
|||||||
<h1 className="text-2xl font-bold text-secondary-900">Rechteverwaltung</h1>
|
<h1 className="text-2xl font-bold text-secondary-900">Rechteverwaltung</h1>
|
||||||
|
|
||||||
<div className="border-b border-secondary-200">
|
<div className="border-b border-secondary-200">
|
||||||
<nav className="flex gap-4" role="tablist" aria-label={t('settingsRechte.rechteverwaltungtabs')}>
|
<nav className="flex gap-4" role="tablist" aria-label="Rechteverwaltung Tabs">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function SettingsSequencesPage() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700">{t('sequences.prefix')}</label>
|
<label className="block text-sm font-medium text-secondary-700">{t('sequences.prefix')}</label>
|
||||||
<input type="text" {...register('prefix')} placeholder={t('settingsSequences.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>}
|
{errors.prefix && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.prefix.message)}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -167,10 +167,10 @@ function AdressenTab() {
|
|||||||
{
|
{
|
||||||
key: 'actions', header: '', render: (row) => (
|
key: 'actions', header: '', render: (row) => (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label={t('settingsStammdaten.bearbeiten')}>
|
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label={t('settingsStammdaten.löschen')}>
|
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,14 +180,14 @@ function AdressenTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card title={t('settingsStammdaten.adressverwaltung')} actions={
|
<Card title="Adressverwaltung" actions={
|
||||||
<Button size="sm" onClick={handleAdd}>
|
<Button size="sm" onClick={handleAdd}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t('common.add', 'Hinzufügen')}
|
{t('common.add', 'Hinzufügen')}
|
||||||
</Button>
|
</Button>
|
||||||
}>
|
}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-4 text-secondary-500">{t('settingsStammdaten.ladeadressen')}</div>
|
<div className="text-center py-4 text-secondary-500">Lade Adressen...</div>
|
||||||
) : (
|
) : (
|
||||||
<Table columns={columns} data={addresses} rowKey={(row) => row.id} emptyMessage="Noch keine Adressen angelegt" />
|
<Table columns={columns} data={addresses} rowKey={(row) => row.id} emptyMessage="Noch keine Adressen angelegt" />
|
||||||
)}
|
)}
|
||||||
@@ -352,10 +352,10 @@ function KontenTab() {
|
|||||||
{
|
{
|
||||||
key: 'actions', header: '', render: (row) => (
|
key: 'actions', header: '', render: (row) => (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label={t('settingsStammdaten.bearbeiten')}>
|
<button onClick={() => handleEdit(row)} className="p-1 rounded hover:bg-secondary-100" aria-label="Bearbeiten">
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label={t('settingsStammdaten.löschen')}>
|
<button onClick={() => handleDelete(row.id)} className="p-1 rounded hover:bg-danger-50 text-danger-600" aria-label="Löschen">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -365,14 +365,14 @@ function KontenTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card title={t('settingsStammdaten.bankkontenverwaltung')} actions={
|
<Card title="Bankkontenverwaltung" actions={
|
||||||
<Button size="sm" onClick={handleAdd}>
|
<Button size="sm" onClick={handleAdd}>
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
{t('common.add', 'Hinzufügen')}
|
{t('common.add', 'Hinzufügen')}
|
||||||
</Button>
|
</Button>
|
||||||
}>
|
}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-4 text-secondary-500">{t('settingsStammdaten.ladekonten')}</div>
|
<div className="text-center py-4 text-secondary-500">Lade Konten...</div>
|
||||||
) : (
|
) : (
|
||||||
<Table columns={columns} data={accounts} rowKey={(row) => row.id} emptyMessage="Noch keine Konten angelegt" />
|
<Table columns={columns} data={accounts} rowKey={(row) => row.id} emptyMessage="Noch keine Konten angelegt" />
|
||||||
)}
|
)}
|
||||||
@@ -447,7 +447,7 @@ export function SettingsStammdatenPage() {
|
|||||||
<h1 className="text-2xl font-bold text-secondary-900">Stammdaten</h1>
|
<h1 className="text-2xl font-bold text-secondary-900">Stammdaten</h1>
|
||||||
|
|
||||||
<div className="border-b border-secondary-200">
|
<div className="border-b border-secondary-200">
|
||||||
<nav className="flex gap-4" role="tablist" aria-label={t('settingsStammdaten.stammdatentabs')}>
|
<nav className="flex gap-4" role="tablist" aria-label="Stammdaten Tabs">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function SettingsSystemPage() {
|
|||||||
<h1 className="text-2xl font-bold text-secondary-900">System</h1>
|
<h1 className="text-2xl font-bold text-secondary-900">System</h1>
|
||||||
|
|
||||||
<div className="border-b border-secondary-200">
|
<div className="border-b border-secondary-200">
|
||||||
<nav className="flex gap-4" role="tablist" aria-label={t('settingsSystem.systemtabs')}>
|
<nav className="flex gap-4" role="tablist" aria-label="System Tabs">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export function SettingsTaxesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-secondary-700">{t('taxes.country')}</label>
|
<label className="block text-sm font-medium text-secondary-700">{t('taxes.country')}</label>
|
||||||
<input type="text" {...register('country')} maxLength={2} placeholder={t('settingsTaxes.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>}
|
{errors.country && <p className="text-xs text-danger-600 mt-1">{errorMsg(errors.country.message)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export function SettingsThemePage() {
|
|||||||
value={primaryColor}
|
value={primaryColor}
|
||||||
onChange={(e) => handlePrimaryChange(e.target.value)}
|
onChange={(e) => handlePrimaryChange(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
placeholder={t('settingsTheme.2563eb')}
|
placeholder="#2563eb"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +239,7 @@ export function SettingsThemePage() {
|
|||||||
value={accentColor}
|
value={accentColor}
|
||||||
onChange={(e) => handleAccentChange(e.target.value)}
|
onChange={(e) => handleAccentChange(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
placeholder={t('settingsTheme.d946ef')}
|
placeholder="#d946ef"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,10 +302,10 @@ export function SettingsThemePage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="primary" size="sm">{t('settingsTheme.primarybutton')}</Button>
|
<Button variant="primary" size="sm">Primary Button</Button>
|
||||||
<Button variant="secondary" size="sm">{t('settingsTheme.secondarybutton')}</Button>
|
<Button variant="secondary" size="sm">Secondary Button</Button>
|
||||||
<Button variant="danger" size="sm">{t('settingsTheme.dangerbutton')}</Button>
|
<Button variant="danger" size="sm">Danger Button</Button>
|
||||||
<Button variant="ghost" size="sm">{t('settingsTheme.ghostbutton')}</Button>
|
<Button variant="ghost" size="sm">Ghost Button</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* Badges */}
|
{/* Badges */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -317,11 +317,11 @@ export function SettingsThemePage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Input preview */}
|
{/* Input preview */}
|
||||||
<div className="max-w-xs">
|
<div className="max-w-xs">
|
||||||
<Input label="Beispiel-Input" placeholder={t('settingsTheme.texteingeben')} />
|
<Input label="Beispiel-Input" placeholder="Text eingeben..." />
|
||||||
</div>
|
</div>
|
||||||
{/* Card preview */}
|
{/* Card preview */}
|
||||||
<div className="bg-white rounded-lg border border-secondary-200 p-4 shadow-sm">
|
<div className="bg-white rounded-lg border border-secondary-200 p-4 shadow-sm">
|
||||||
<p className="text-sm text-secondary-700">{t('settingsTheme.diesisteinebeispielkartemit')}</p>
|
<p className="text-sm text-secondary-700">Dies ist eine Beispiel-Karte mit dem aktuellen Theme.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function SettingsUserManagementPage() {
|
|||||||
<h1 className="text-2xl font-bold text-secondary-900">Nutzerverwaltung</h1>
|
<h1 className="text-2xl font-bold text-secondary-900">Nutzerverwaltung</h1>
|
||||||
|
|
||||||
<div className="border-b border-secondary-200">
|
<div className="border-b border-secondary-200">
|
||||||
<nav className="flex gap-4" role="tablist" aria-label={t('settingsUserManagement.nutzerverwaltungtabs')}>
|
<nav className="flex gap-4" role="tablist" aria-label="Nutzerverwaltung Tabs">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ export function SettingsUsersPage() {
|
|||||||
type="email"
|
type="email"
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
error={errorMsg(errors.email?.message)}
|
error={errorMsg(errors.email?.message)}
|
||||||
placeholder={t('settingsUsers.neumitarbeiterfirmade')}
|
placeholder="neu.mitarbeiter@firma.de"
|
||||||
data-testid="invite-email"
|
data-testid="invite-email"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export function StartPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate('/start')}
|
onClick={() => navigate('/start')}
|
||||||
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
className="p-1.5 rounded-md text-secondary-400 hover:bg-secondary-100 hover:text-secondary-700 flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
aria-label={t('startPage.zurückzurstartseite')}
|
aria-label="Zurück zur Startseite"
|
||||||
title={t('startPage.zurückzurstartseite')}
|
title="Zurück zur Startseite"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -101,7 +101,7 @@ export function StartPage() {
|
|||||||
<div className="max-w-5xl mx-auto p-8">
|
<div className="max-w-5xl mx-auto p-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-2xl font-bold text-secondary-900">Willkommen{user?.first_name ? `, ${user.first_name}` : ''}!</h1>
|
<h1 className="text-2xl font-bold text-secondary-900">Willkommen{user?.first_name ? `, ${user.first_name}` : ''}!</h1>
|
||||||
<p className="text-sm text-secondary-500 mt-1">{t('startPage.wähleeinenworkspaceaus')}</p>
|
<p className="text-sm text-secondary-500 mt-1">Wähle einen Workspace aus</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dashboard stats */}
|
{/* Dashboard stats */}
|
||||||
@@ -171,7 +171,7 @@ export function StartPage() {
|
|||||||
>
|
>
|
||||||
<Plus className="w-8 h-8 text-secondary-300 group-hover:text-primary-400 transition-colors" />
|
<Plus className="w-8 h-8 text-secondary-300 group-hover:text-primary-400 transition-colors" />
|
||||||
<span className="text-sm text-secondary-400 group-hover:text-primary-600 mt-2 transition-colors">
|
<span className="text-sm text-secondary-400 group-hover:text-primary-600 mt-2 transition-colors">
|
||||||
{t('startPage.workspacehinzufügen')}
|
Workspace hinzufügen
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user