feat(crm): add CRM Settings page with module visibility toggles

- New CrmSettingsContext with localStorage persistence (later swappable to API)
- CrmSettingsPage: toggle switches per CRM module (Kontakte, Unternehmen,
  Vorgänge, Pipelines), only accessible for PLATFORM_ADMIN/TENANT_ADMIN
- CrmModuleGuard: route protection for disabled modules (redirect to dashboard)
- Sidebar: NavLinks conditionally rendered based on module settings
- "Einstellungen" NavLink in CRM section (admin-only, gear icon)
- CRM section hidden when all modules disabled and user is not admin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 20:22:16 +01:00
parent 028364cd7d
commit 411a6bbbcb
5 changed files with 693 additions and 121 deletions

View file

@ -0,0 +1,146 @@
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from 'react';
import { Navigate } from 'react-router-dom';
// ============================================================
// Types
// ============================================================
export type CrmModuleKey = 'contacts' | 'companies' | 'deals' | 'pipelines';
export interface CrmModuleConfig {
enabled: boolean;
label?: string; // Für spätere Umbenennung
}
export interface CrmSettings {
modules: Record<CrmModuleKey, CrmModuleConfig>;
}
interface CrmSettingsContextValue {
settings: CrmSettings;
updateSettings: (next: CrmSettings) => void;
toggleModule: (key: CrmModuleKey, enabled: boolean) => void;
isModuleEnabled: (key: CrmModuleKey) => boolean;
}
// ============================================================
// Defaults & localStorage
// ============================================================
const STORAGE_KEY = 'crm-settings';
const DEFAULT_SETTINGS: CrmSettings = {
modules: {
contacts: { enabled: true },
companies: { enabled: true },
deals: { enabled: true },
pipelines: { enabled: true },
},
};
function loadSettings(): CrmSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_SETTINGS;
const parsed = JSON.parse(raw) as Partial<CrmSettings>;
// Merge mit Defaults (falls neue Module hinzukommen)
return {
modules: {
...DEFAULT_SETTINGS.modules,
...(parsed.modules ?? {}),
},
};
} catch {
return DEFAULT_SETTINGS;
}
}
function saveSettings(settings: CrmSettings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {
// localStorage voll oder nicht verfügbar — ignorieren
}
}
// ============================================================
// Context + Provider
// ============================================================
const CrmSettingsCtx = createContext<CrmSettingsContextValue | null>(null);
export function CrmSettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<CrmSettings>(loadSettings);
const updateSettings = useCallback((next: CrmSettings) => {
setSettings(next);
saveSettings(next);
}, []);
const toggleModule = useCallback(
(key: CrmModuleKey, enabled: boolean) => {
setSettings((prev) => {
const next: CrmSettings = {
...prev,
modules: {
...prev.modules,
[key]: { ...prev.modules[key], enabled },
},
};
saveSettings(next);
return next;
});
},
[],
);
const isModuleEnabled = useCallback(
(key: CrmModuleKey) => settings.modules[key]?.enabled ?? true,
[settings],
);
return (
<CrmSettingsCtx.Provider
value={{ settings, updateSettings, toggleModule, isModuleEnabled }}
>
{children}
</CrmSettingsCtx.Provider>
);
}
// ============================================================
// Hook
// ============================================================
export function useCrmSettings(): CrmSettingsContextValue {
const ctx = useContext(CrmSettingsCtx);
if (!ctx) {
throw new Error('useCrmSettings must be used within CrmSettingsProvider');
}
return ctx;
}
// ============================================================
// Route Guard
// ============================================================
/**
* Wraps a CRM page redirects to "/" if the module is disabled.
*/
export function CrmModuleGuard({
module: mod,
children,
}: {
module: CrmModuleKey;
children: ReactNode;
}) {
const { isModuleEnabled } = useCrmSettings();
if (!isModuleEnabled(mod)) return <Navigate to="/" replace />;
return <>{children}</>;
}

View file

@ -0,0 +1,151 @@
/* CRM Settings Page */
.backLink {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-muted);
font-size: 0.875rem;
text-decoration: none;
margin-bottom: 1.5rem;
}
.backLink:hover {
color: var(--color-text);
}
.card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.cardTitle {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.cardDesc {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin: 0 0 1.25rem;
}
/* Module list */
.moduleList {
display: flex;
flex-direction: column;
gap: 0;
}
.moduleItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
border-bottom: 1px solid var(--color-border);
}
.moduleItem:last-child {
border-bottom: none;
padding-bottom: 0;
}
.moduleItem:first-child {
padding-top: 0;
}
.moduleInfo {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.moduleName {
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-text);
}
.moduleDesc {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
/* Toggle switch */
.toggle {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggleTrack {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-border);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s;
}
.toggle input:checked + .toggleTrack {
background: var(--color-primary);
}
.toggleTrack::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.toggle input:checked + .toggleTrack::after {
transform: translateX(20px);
}
.toggle input:focus-visible + .toggleTrack {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Warning */
.warning {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: #fefce8;
border: 1px solid #fde68a;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
color: #92400e;
margin-top: 1.25rem;
}
/* Dark mode override for warning */
:global([data-theme='dark']) .warning {
background: #422006;
border-color: #854d0e;
color: #fde68a;
}

View file

@ -0,0 +1,225 @@
import { Link, Navigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext';
import {
useCrmSettings,
type CrmModuleKey,
} from './CrmSettingsContext';
import styles from './CrmSettingsPage.module.css';
// ============================================================
// Module definitions (UI-Beschreibung je Modul)
// ============================================================
interface ModuleDef {
key: CrmModuleKey;
name: string;
description: string;
icon: React.ReactNode;
}
const iconProps = {
width: 18,
height: 18,
viewBox: '0 0 16 16',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
};
const MODULES: ModuleDef[] = [
{
key: 'contacts',
name: 'Kontakte',
description: 'Kontaktverwaltung (Personen & Organisationen)',
icon: (
<svg {...iconProps}>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.5 2.5-4.5 6-4.5s6 2 6 4.5" />
</svg>
),
},
{
key: 'companies',
name: 'Unternehmen',
description: 'Unternehmensverwaltung mit Verknüpfung zu Kontakten & Vorgängen',
icon: (
<svg {...iconProps}>
<rect x="2" y="6" width="12" height="9" rx="1" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
<path d="M5 9h2v2H5zM9 9h2v2H9z" />
</svg>
),
},
{
key: 'deals',
name: 'Vorgänge',
description: 'Vorgänge & Sales-Pipeline-Tracking',
icon: (
<svg {...iconProps}>
<path d="M2 2h3l2 9h6l2-6H6" />
<circle cx="7" cy="14" r="1" />
<circle cx="13" cy="14" r="1" />
</svg>
),
},
{
key: 'pipelines',
name: 'Pipelines',
description: 'Pipeline-Konfiguration & Stufen-Management',
icon: (
<svg {...iconProps}>
<rect x="1" y="2" width="14" height="3" rx="0.5" />
<rect x="1" y="7" width="10" height="3" rx="0.5" />
<rect x="1" y="12" width="6" height="3" rx="0.5" />
</svg>
),
},
];
// ============================================================
// Page Component
// ============================================================
export function CrmSettingsPage() {
const { user } = useAuth();
const { settings, toggleModule } = useCrmSettings();
// Zugriffskontrolle: nur Admins
if (
user?.role !== 'PLATFORM_ADMIN' &&
user?.role !== 'TENANT_ADMIN'
) {
return <Navigate to="/" replace />;
}
const anyDisabled = Object.values(settings.modules).some(
(m) => !m.enabled,
);
return (
<div>
{/* Zurück */}
<Link to="/" className={styles.backLink}>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M9 2L4 7l5 5" />
</svg>
Zurück zum Dashboard
</Link>
{/* Header */}
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '1.5rem' }}>
CRM Einstellungen
</h1>
{/* Module-Card */}
<div className={styles.card}>
<h2 className={styles.cardTitle}>Module</h2>
<p className={styles.cardDesc}>
Aktiviere oder deaktiviere einzelne CRM-Module. Deaktivierte Module
werden aus dem Menü ausgeblendet.
</p>
<div className={styles.moduleList}>
{MODULES.map((mod) => {
const enabled = settings.modules[mod.key]?.enabled ?? true;
return (
<div key={mod.key} className={styles.moduleItem}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
}}
>
<span
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 'var(--radius-sm)',
background: enabled
? 'var(--color-primary-bg, #eff6ff)'
: 'var(--color-bg)',
color: enabled
? 'var(--color-primary)'
: 'var(--color-text-muted)',
transition: 'background 0.2s, color 0.2s',
}}
>
{mod.icon}
</span>
<div className={styles.moduleInfo}>
<span
className={styles.moduleName}
style={{
opacity: enabled ? 1 : 0.5,
transition: 'opacity 0.2s',
}}
>
{mod.name}
</span>
<span className={styles.moduleDesc}>{mod.description}</span>
</div>
</div>
<label className={styles.toggle}>
<input
type="checkbox"
checked={enabled}
onChange={(e) =>
toggleModule(mod.key, e.target.checked)
}
/>
<span className={styles.toggleTrack} />
</label>
</div>
);
})}
</div>
{anyDisabled && (
<div className={styles.warning}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0, marginTop: 1 }}
>
<path d="M8 1L1 14h14L8 1z" />
<path d="M8 6v3M8 11.5v.5" />
</svg>
<span>
Deaktivierte Module werden aus dem Menü ausgeblendet.
Bestehende Daten bleiben erhalten und sind nach Reaktivierung
wieder verfügbar.
</span>
</div>
)}
</div>
{/* Platzhalter für zukünftige Einstellungen */}
<div className={styles.card} style={{ opacity: 0.6 }}>
<h2 className={styles.cardTitle}>Weitere Einstellungen</h2>
<p className={styles.cardDesc}>
Zusätzliche Konfigurationsmöglichkeiten werden in zukünftigen
Versionen verfügbar sein.
</p>
</div>
</div>
);
}

View file

@ -18,6 +18,8 @@ import { DealDetailPage } from '../crm/deals/DealDetailPage';
import { PipelinesPage } from '../crm/pipelines/PipelinesPage';
import { CompaniesPage } from '../crm/companies/CompaniesPage';
import { CompanyDetailPage } from '../crm/companies/CompanyDetailPage';
import { CrmSettingsProvider, CrmModuleGuard } from '../crm/settings/CrmSettingsContext';
import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@ -49,20 +51,23 @@ export function App() {
path="/"
element={
<PrivateRoute>
<AppLayout />
<CrmSettingsProvider>
<AppLayout />
</CrmSettingsProvider>
</PrivateRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="profile" element={<ProfilePage />} />
{/* CRM-Bereich */}
<Route path="crm/contacts" element={<ContactsPage />} />
<Route path="crm/contacts/:id" element={<ContactDetailPage />} />
<Route path="crm/companies" element={<CompaniesPage />} />
<Route path="crm/companies/:id" element={<CompanyDetailPage />} />
<Route path="crm/deals" element={<DealsPage />} />
<Route path="crm/deals/:id" element={<DealDetailPage />} />
<Route path="crm/pipelines" element={<PipelinesPage />} />
<Route path="crm/contacts" element={<CrmModuleGuard module="contacts"><ContactsPage /></CrmModuleGuard>} />
<Route path="crm/contacts/:id" element={<CrmModuleGuard module="contacts"><ContactDetailPage /></CrmModuleGuard>} />
<Route path="crm/companies" element={<CrmModuleGuard module="companies"><CompaniesPage /></CrmModuleGuard>} />
<Route path="crm/companies/:id" element={<CrmModuleGuard module="companies"><CompanyDetailPage /></CrmModuleGuard>} />
<Route path="crm/deals" element={<CrmModuleGuard module="deals"><DealsPage /></CrmModuleGuard>} />
<Route path="crm/deals/:id" element={<CrmModuleGuard module="deals"><DealDetailPage /></CrmModuleGuard>} />
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} />
<Route path="crm/settings" element={<CrmSettingsPage />} />
{/* Admin-Bereich mit eigenem Layout (Top-Tabs) */}
<Route path="admin" element={<AdminLayout />}>
<Route index element={<Navigate to="users" replace />} />

View file

@ -5,6 +5,7 @@ import { useAuth } from '../auth/AuthContext';
import { UserAvatar } from '../components/UserAvatar';
import api from '../api/client';
import { useTheme } from '../theme/ThemeContext';
import { useCrmSettings } from '../crm/settings/CrmSettingsContext';
import styles from './AppLayout.module.css';
interface ExternalLink {
@ -112,6 +113,13 @@ export function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { mode, setMode } = useTheme();
const { isModuleEnabled } = useCrmSettings();
const isAdmin = user?.role === 'PLATFORM_ADMIN' || user?.role === 'TENANT_ADMIN';
const anyCrmModuleEnabled =
isModuleEnabled('contacts') ||
isModuleEnabled('companies') ||
isModuleEnabled('deals') ||
isModuleEnabled('pipelines');
const [crmOpen, setCrmOpen] = useState(true);
const [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(() => {
@ -234,121 +242,158 @@ export function AppLayout() {
{!collapsed && 'Dashboard'}
</NavLink>
{/* CRM-Bereich (aufklappbar) */}
{!collapsed ? (
<button
className={styles.navSectionToggle}
onClick={() => setCrmOpen((p) => !p)}
>
<span>CRM</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`${styles.chevron} ${crmOpen ? styles.chevronOpen : ''}`}
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
) : (
<div className={styles.navDivider} />
)}
{(crmOpen || collapsed) && (
{/* CRM-Bereich (aufklappbar) — nur sichtbar wenn Module aktiv oder Admin */}
{(anyCrmModuleEnabled || isAdmin) && (
<>
<NavLink
to="/crm/contacts"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Kontakte"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
{!collapsed ? (
<button
className={styles.navSectionToggle}
onClick={() => setCrmOpen((p) => !p)}
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.761 2.686-5 6-5s6 2.239 6 5" />
</svg>
{!collapsed && 'Kontakte'}
</NavLink>
<NavLink
to="/crm/companies"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Unternehmen"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="6" width="12" height="9" rx="1" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
<path d="M5 9h2v2H5zM9 9h2v2H9z" />
</svg>
{!collapsed && 'Unternehmen'}
</NavLink>
<NavLink
to="/crm/deals"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Vorgänge"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="5" width="14" height="9" rx="1" />
<path d="M5 5V3a2 2 0 012-2h2a2 2 0 012 2v2" />
<path d="M1 9h14" />
</svg>
{!collapsed && 'Vorgänge'}
</NavLink>
<NavLink
to="/crm/pipelines"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Pipelines"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="2" width="4" height="12" rx="0.5" />
<rect x="6" y="2" width="4" height="8" rx="0.5" />
<rect x="11" y="2" width="4" height="10" rx="0.5" />
</svg>
{!collapsed && 'Pipelines'}
</NavLink>
<span>CRM</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`${styles.chevron} ${crmOpen ? styles.chevronOpen : ''}`}
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
) : (
<div className={styles.navDivider} />
)}
{(crmOpen || collapsed) && (
<>
{isModuleEnabled('contacts') && (
<NavLink
to="/crm/contacts"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Kontakte"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.761 2.686-5 6-5s6 2.239 6 5" />
</svg>
{!collapsed && 'Kontakte'}
</NavLink>
)}
{isModuleEnabled('companies') && (
<NavLink
to="/crm/companies"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Unternehmen"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="6" width="12" height="9" rx="1" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
<path d="M5 9h2v2H5zM9 9h2v2H9z" />
</svg>
{!collapsed && 'Unternehmen'}
</NavLink>
)}
{isModuleEnabled('deals') && (
<NavLink
to="/crm/deals"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Vorgänge"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="5" width="14" height="9" rx="1" />
<path d="M5 5V3a2 2 0 012-2h2a2 2 0 012 2v2" />
<path d="M1 9h14" />
</svg>
{!collapsed && 'Vorgänge'}
</NavLink>
)}
{isModuleEnabled('pipelines') && (
<NavLink
to="/crm/pipelines"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Pipelines"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="2" width="4" height="12" rx="0.5" />
<rect x="6" y="2" width="4" height="8" rx="0.5" />
<rect x="11" y="2" width="4" height="10" rx="0.5" />
</svg>
{!collapsed && 'Pipelines'}
</NavLink>
)}
{/* CRM Einstellungen (nur Admins) */}
{isAdmin && (
<NavLink
to="/crm/settings"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="CRM Einstellungen"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="8" r="2" />
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
</svg>
{!collapsed && 'Einstellungen'}
</NavLink>
)}
</>
)}
</>
)}