From 4e5c26cadd8512d89970c1f3330e467917d66d1a Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Wed, 11 Mar 2026 09:07:16 +0100 Subject: [PATCH] feat(frontend): add tabbed layout to CRM Settings page - Restructure CRM Settings into 3 tabs: Module | Lexoffice Sync | Weitere Einstellungen - Extract LexwareSyncContent as reusable component from LexwareSyncPage - Embed Import/Export directly in Settings "Lexoffice Sync" tab - Move Industries, AccountTypes, RelationshipTypes configs to "Weitere Einstellungen" tab - Keep standalone /crm/lexware-sync route as fallback - Add tab bar styles matching existing design pattern Co-Authored-By: Claude Opus 4.6 --- .../src/crm/lexware/LexwareSyncPage.tsx | 99 +- .../crm/settings/CrmSettingsPage.module.css | 279 ++++++ .../src/crm/settings/CrmSettingsPage.tsx | 918 +++++++++++++++--- 3 files changed, 1107 insertions(+), 189 deletions(-) diff --git a/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx b/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx index c8f9478..5869bbb 100644 --- a/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx +++ b/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx @@ -776,54 +776,17 @@ function ExportTab() { } // ============================================================ -// LexwareSyncPage — Main +// LexwareSyncContent — Reusable Import/Export tabs // ============================================================ -type TabKey = 'import' | 'export'; +type SyncTabKey = 'import' | 'export'; -export function LexwareSyncPage() { - const { user } = useAuth(); - const { isModuleEnabled } = useCrmSettings(); - const [activeTab, setActiveTab] = useState('import'); - - // Zugriffskontrolle - if ( - user?.role !== 'PLATFORM_ADMIN' && - user?.role !== 'TENANT_ADMIN' - ) { - return ; - } - - if (!isModuleEnabled('lexware')) { - return ; - } +export function LexwareSyncContent() { + const [activeTab, setActiveTab] = useState('import'); return ( -
- {/* Zurück */} - - - - - Zurück zu CRM Einstellungen - - - {/* Header */} -
-

- LX - Lexware Office Synchronisation -

-
- - {/* Tab Bar */} + <> + {/* Sub-Tab Bar */}
+ + ); +} + +// ============================================================ +// LexwareSyncPage — Standalone page (fallback route) +// ============================================================ + +export function LexwareSyncPage() { + const { user } = useAuth(); + const { isModuleEnabled } = useCrmSettings(); + + // Zugriffskontrolle + if ( + user?.role !== 'PLATFORM_ADMIN' && + user?.role !== 'TENANT_ADMIN' + ) { + return ; + } + + if (!isModuleEnabled('lexware')) { + return ; + } + + return ( +
+ {/* Zurück */} + + + + + Zurück zu CRM Einstellungen + + + {/* Header */} +
+

+ LX + Lexware Office Synchronisation +

+
+ +
); } diff --git a/packages/frontend/src/crm/settings/CrmSettingsPage.module.css b/packages/frontend/src/crm/settings/CrmSettingsPage.module.css index 3478308..52319b5 100644 --- a/packages/frontend/src/crm/settings/CrmSettingsPage.module.css +++ b/packages/frontend/src/crm/settings/CrmSettingsPage.module.css @@ -149,3 +149,282 @@ border-color: #854d0e; color: #fde68a; } + +/* ============================================================ */ +/* Config Lists (Industries, AccountTypes, RelationshipTypes) */ +/* ============================================================ */ + +.configHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.configHeader h2 { + margin: 0; +} + +.addBtn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-primary); + background: var(--color-primary-bg, #eff6ff); + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.addBtn:hover { + background: var(--color-primary); + color: white; +} + +.configTable { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.configTable th { + text-align: left; + padding: 0.5rem 0.75rem; + font-weight: 500; + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +.configTable td { + padding: 0.625rem 0.75rem; + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} + +.configTable tr:last-child td { + border-bottom: none; +} + +.colorDot { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.1); + vertical-align: middle; +} + +.actionsCell { + display: flex; + align-items: center; + gap: 0.25rem; + justify-content: flex-end; +} + +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.iconBtn:hover { + background: var(--color-bg); + color: var(--color-text); +} + +.iconBtnDanger:hover { + background: #fef2f2; + color: #dc2626; +} + +:global([data-theme='dark']) .iconBtnDanger:hover { + background: #450a0a; + color: #fca5a5; +} + +/* Inline edit row */ +.inlineForm { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.inlineInput { + padding: 0.375rem 0.625rem; + font-size: 0.8125rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text); + outline: none; + transition: border-color 0.15s; +} + +.inlineInput:focus { + border-color: var(--color-primary); +} + +.inlineInput::placeholder { + color: var(--color-text-muted); +} + +.colorInput { + width: 32px; + height: 28px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + background: none; +} + +.colorInput::-webkit-color-swatch-wrapper { + padding: 2px; +} + +.colorInput::-webkit-color-swatch { + border: none; + border-radius: 3px; +} + +.saveBtn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + color: white; + background: var(--color-primary); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: opacity 0.15s; +} + +.saveBtn:hover { + opacity: 0.9; +} + +.saveBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cancelBtn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-muted); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s; +} + +.cancelBtn:hover { + background: var(--color-bg); +} + +.emptyRow { + color: var(--color-text-muted); + font-style: italic; + text-align: center; +} + +.sortBtns { + display: flex; + flex-direction: column; + gap: 0; +} + +.sortBtn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 14px; + padding: 0; + border: none; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + font-size: 0.5rem; + line-height: 1; +} + +.sortBtn:hover { + color: var(--color-primary); +} + +.sortBtn:disabled { + opacity: 0.25; + cursor: not-allowed; +} + +/* ============================================================ */ +/* Settings Tab Bar */ +/* ============================================================ */ + +.settingsTabBar { + display: flex; + gap: 0; + border-bottom: 2px solid var(--color-border); + margin-bottom: 1.5rem; +} + +.settingsTab { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.settingsTab:hover { + color: var(--color-text); +} + +.settingsTabActive { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* Disabled hint (e.g. Lexware module not active) */ +.disabledHint { + display: flex; + align-items: flex-start; + gap: 0.75rem; + color: var(--color-text-muted); +} + +.disabledHint svg { + flex-shrink: 0; + margin-top: 0.125rem; +} diff --git a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx index efb44c4..9124baf 100644 --- a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx +++ b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx @@ -1,9 +1,30 @@ +import { useState, useCallback } from 'react'; import { Link, Navigate } from 'react-router-dom'; import { useAuth } from '../../auth/AuthContext'; import { useCrmSettings, type CrmModuleKey, } from './CrmSettingsContext'; +import { + useIndustries, + useCreateIndustry, + useUpdateIndustry, + useDeleteIndustry, + useAccountTypes, + useCreateAccountType, + useUpdateAccountType, + useDeleteAccountType, + useRelationshipTypes, + useCreateRelationshipType, + useUpdateRelationshipType, + useDeleteRelationshipType, +} from '../hooks'; +import type { + Industry, + AccountType, + RelationshipType, +} from '../types'; +import { LexwareSyncContent } from '../lexware/LexwareSyncPage'; import styles from './CrmSettingsPage.module.css'; // ============================================================ @@ -90,13 +111,578 @@ const MODULES: ModuleDef[] = [ }, ]; +// ============================================================ +// Kleine SVG-Icons +// ============================================================ + +const PlusIcon = () => ( + + + +); + +const PencilIcon = () => ( + + + +); + +const TrashIcon = () => ( + + + +); + +const ArrowUpIcon = () => ( + + + +); + +const ArrowDownIcon = () => ( + + + +); + +// ============================================================ +// IndustriesConfig +// ============================================================ + +function IndustriesConfig() { + const { data } = useIndustries(); + const createMut = useCreateIndustry(); + const updateMut = useUpdateIndustry(); + const deleteMut = useDeleteIndustry(); + + const industries: Industry[] = data?.data ?? []; + + const [editId, setEditId] = useState(null); + const [addMode, setAddMode] = useState(false); + const [name, setName] = useState(''); + const [color, setColor] = useState('#6B7280'); + + const startAdd = useCallback(() => { + setEditId(null); + setName(''); + setColor('#6B7280'); + setAddMode(true); + }, []); + + const startEdit = useCallback((item: Industry) => { + setAddMode(false); + setEditId(item.id); + setName(item.name); + setColor(item.color); + }, []); + + const cancel = useCallback(() => { + setEditId(null); + setAddMode(false); + setName(''); + setColor('#6B7280'); + }, []); + + const handleSave = useCallback(() => { + if (!name.trim()) return; + if (addMode) { + createMut.mutate( + { name: name.trim(), color }, + { onSuccess: cancel }, + ); + } else if (editId) { + updateMut.mutate( + { id: editId, data: { name: name.trim(), color } }, + { onSuccess: cancel }, + ); + } + }, [addMode, editId, name, color, createMut, updateMut, cancel]); + + const handleDelete = useCallback( + (id: string) => { + if (window.confirm('Branche wirklich löschen?')) { + deleteMut.mutate(id); + } + }, + [deleteMut], + ); + + const handleSort = useCallback( + (item: Industry, direction: 'up' | 'down') => { + const newOrder = + direction === 'up' + ? Math.max(0, item.sortOrder - 1) + : item.sortOrder + 1; + updateMut.mutate({ id: item.id, data: { sortOrder: newOrder } }); + }, + [updateMut], + ); + + const isSaving = createMut.isPending || updateMut.isPending; + + return ( +
+
+
+

Branchen

+

+ Branchen-Kategorien für Unternehmen. Farben werden als Badge angezeigt. +

+
+ +
+ + + + + + + + + + + + {addMode && ( + + + + + + + )} + {industries.length === 0 && !addMode && ( + + + + )} + {industries.map((item, idx) => + editId === item.id ? ( + + + + + + + ) : ( + + + + + + + ), + )} + +
#NameFarbeAktionen
+
+ setName(e.target.value)} + placeholder="Branchenname" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') cancel(); + }} + /> +
+
+ setColor(e.target.value)} + /> + +
+ + +
+
+ Noch keine Branchen definiert +
{idx + 1} +
+ setName(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') cancel(); + }} + /> +
+
+ setColor(e.target.value)} + /> + +
+ + +
+
+
+ + +
+
{item.name} + + +
+ + +
+
+
+ ); +} + +// ============================================================ +// Generic ConfigList (for AccountTypes & RelationshipTypes) +// ============================================================ + +interface ConfigListItem { + id: string; + name: string; + sortOrder: number; + _count?: Record; +} + +interface ConfigListProps { + title: string; + description: string; + items: T[]; + deleteConfirmText: string; + emptyText: string; + onCreate: (data: { name: string; sortOrder?: number }) => void; + onUpdate: (id: string, data: { name?: string; sortOrder?: number }) => void; + onDelete: (id: string) => void; + isCreating: boolean; + isUpdating: boolean; +} + +function ConfigList({ + title, + description, + items, + deleteConfirmText, + emptyText, + onCreate, + onUpdate, + onDelete, + isCreating, + isUpdating, +}: ConfigListProps) { + const [editId, setEditId] = useState(null); + const [addMode, setAddMode] = useState(false); + const [name, setName] = useState(''); + + const startAdd = useCallback(() => { + setEditId(null); + setName(''); + setAddMode(true); + }, []); + + const startEdit = useCallback((item: T) => { + setAddMode(false); + setEditId(item.id); + setName(item.name); + }, []); + + const cancel = useCallback(() => { + setEditId(null); + setAddMode(false); + setName(''); + }, []); + + const handleSave = useCallback(() => { + if (!name.trim()) return; + if (addMode) { + onCreate({ name: name.trim() }); + cancel(); + } else if (editId) { + onUpdate(editId, { name: name.trim() }); + cancel(); + } + }, [addMode, editId, name, onCreate, onUpdate, cancel]); + + const handleDelete = useCallback( + (id: string) => { + if (window.confirm(deleteConfirmText)) { + onDelete(id); + } + }, + [onDelete, deleteConfirmText], + ); + + const handleSort = useCallback( + (item: T, direction: 'up' | 'down') => { + const newOrder = + direction === 'up' + ? Math.max(0, item.sortOrder - 1) + : item.sortOrder + 1; + onUpdate(item.id, { sortOrder: newOrder }); + }, + [onUpdate], + ); + + const isSaving = isCreating || isUpdating; + + return ( +
+
+
+

{title}

+

+ {description} +

+
+ +
+ + + + + + + + + + + {addMode && ( + + + + + + )} + {items.length === 0 && !addMode && ( + + + + )} + {items.map((item, idx) => + editId === item.id ? ( + + + + + + ) : ( + + + + + + ), + )} + +
#NameAktionen
+
+ setName(e.target.value)} + placeholder="Name eingeben" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') cancel(); + }} + /> +
+
+
+ + +
+
+ {emptyText} +
{idx + 1} +
+ setName(e.target.value)} + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') cancel(); + }} + /> +
+
+
+ + +
+
+
+ + +
+
{item.name} +
+ + +
+
+
+ ); +} + +// ============================================================ +// AccountTypesConfig +// ============================================================ + +function AccountTypesConfig() { + const { data } = useAccountTypes(); + const createMut = useCreateAccountType(); + const updateMut = useUpdateAccountType(); + const deleteMut = useDeleteAccountType(); + + const items: AccountType[] = data?.data ?? []; + + return ( + createMut.mutate(d)} + onUpdate={(id, d) => updateMut.mutate({ id, data: d })} + onDelete={(id) => deleteMut.mutate(id)} + isCreating={createMut.isPending} + isUpdating={updateMut.isPending} + /> + ); +} + +// ============================================================ +// RelationshipTypesConfig +// ============================================================ + +function RelationshipTypesConfig() { + const { data } = useRelationshipTypes(); + const createMut = useCreateRelationshipType(); + const updateMut = useUpdateRelationshipType(); + const deleteMut = useDeleteRelationshipType(); + + const items: RelationshipType[] = data?.data ?? []; + + return ( + createMut.mutate(d)} + onUpdate={(id, d) => updateMut.mutate({ id, data: d })} + onDelete={(id) => deleteMut.mutate(id)} + isCreating={createMut.isPending} + isUpdating={updateMut.isPending} + /> + ); +} + // ============================================================ // Page Component // ============================================================ +type SettingsTab = 'module' | 'lexware' | 'settings'; + export function CrmSettingsPage() { const { user } = useAuth(); - const { settings, toggleModule } = useCrmSettings(); + const { settings, toggleModule, isModuleEnabled } = useCrmSettings(); + const [activeTab, setActiveTab] = useState('module'); // Zugriffskontrolle: nur Admins if ( @@ -132,169 +718,209 @@ export function CrmSettingsPage() { CRM Einstellungen - {/* Module-Card */} -
-

Module

-

- Aktiviere oder deaktiviere einzelne CRM-Module. Deaktivierte Module - werden aus dem Menü ausgeblendet. -

+ {/* Top-Level Tab Bar */} +
+ + + +
-
- {MODULES.map((mod) => { - const enabled = settings.modules[mod.key]?.enabled ?? true; - return ( -
-
- +

Module

+

+ Aktiviere oder deaktiviere einzelne CRM-Module. Deaktivierte Module + werden aus dem Menü ausgeblendet. +

+ +
+ {MODULES.map((mod) => { + const enabled = settings.modules[mod.key]?.enabled ?? true; + return ( +
+
- {mod.icon} - -
- {mod.name} + {mod.icon} - {mod.description} +
+ + {mod.name} + + + {mod.description} + +
+
- -
- ); - })} -
- - {anyDisabled && ( -
- - - - - - Deaktivierte Module werden aus dem Menü ausgeblendet. - Bestehende Daten bleiben erhalten und sind nach Reaktivierung - wieder verfügbar. - + ); + })}
- )} -
- {/* Lexware Synchronisation — nur wenn Modul aktiv */} - {settings.modules.lexware?.enabled && ( -
-

- - LX - - Lexware Office Synchronisation -

-

- Kontakte aus Lexware Office importieren oder CRM-Daten nach Lexware - exportieren. -

- - - - - - Import / Export öffnen - + {anyDisabled && ( +
+ + + + + + Deaktivierte Module werden aus dem Menü ausgeblendet. + Bestehende Daten bleiben erhalten und sind nach Reaktivierung + wieder verfügbar. + +
+ )}
)} - {/* Platzhalter für zukünftige Einstellungen */} -
-

Weitere Einstellungen

-

- Zusätzliche Konfigurationsmöglichkeiten werden in zukünftigen - Versionen verfügbar sein. -

-
+ {/* Tab: Lexoffice Sync */} + {activeTab === 'lexware' && ( + <> + {isModuleEnabled('lexware') ? ( + + ) : ( +
+
+ + + + +
+ Lexware Office Modul deaktiviert +

+ Aktiviere das Lexware Office Modul im Tab "Module", + um die Import/Export-Funktionen nutzen zu können. +

+
+
+
+ )} + + )} + + {/* Tab: Weitere Einstellungen */} + {activeTab === 'settings' && ( + <> + + + + + )}
); }