diff --git a/src/App.tsx b/src/App.tsx index de17105..51b89f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,40 @@ +/** + * App.tsx + * + * Haupt-App-Komponente mit Multi-Tenant Router-Setup. + * + * URL-Struktur: + * - / → Dashboard/Übersicht + * - /settings → Benutzer-Einstellungen + * - /mandates/:mandateId/:featureCode/:instanceId/* → Feature-Instanz-Routen + * - /admin/* → System-Administration (nur SysAdmin) + */ + import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { useEffect } from 'react'; // Import global CSS reset first import './index.css'; +// Auth Pages (Public) import Login from './pages/Login'; import Register from './pages/Register'; import PasswordResetRequest from './pages/PasswordResetRequest'; import Reset from './pages/Reset'; +// Providers import { AuthProvider } from './providers/auth/AuthProvider'; import { ProtectedRoute } from './providers/auth/ProtectedRoute'; import { LanguageProvider } from './providers/language/LanguageContext'; -import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext'; -import { FileProvider } from './contexts/FileContext'; -import Home from './pages/Home/Home'; + +// Layouts +import { MainLayout } from './layouts/MainLayout'; +import { FeatureLayout } from './layouts/FeatureLayout'; + +// Pages +import { DashboardPage } from './pages/Dashboard'; +import { SettingsPage } from './pages/Settings'; +import { FeatureViewPage } from './pages/FeatureView'; function App() { // Load saved theme preference and set app name on app mount @@ -38,36 +58,75 @@ function App() { } document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); }, []); + return ( - {/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */} + {/* ================================================== */} + {/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */} + {/* ================================================== */} } /> } /> } /> } /> - {/* PROTECTED ROUTE - requires authentication */} + {/* ================================================== */} + {/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */} + {/* ================================================== */} - - - - - + - } /> + }> + {/* Dashboard (Root) */} + } /> + + {/* System-Seiten (ohne Instanz-Kontext) */} + } /> + + {/* ============================================== */} + {/* FEATURE-INSTANZ ROUTES */} + {/* /mandates/:mandateId/:featureCode/:instanceId */} + {/* ============================================== */} + } + > + {/* Feature Views - dynamisch basierend auf featureCode */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Catch-all für unbekannte Sub-Pfade */} + } /> + + + {/* ============================================== */} + {/* ADMIN ROUTES (nur SysAdmin) */} + {/* ============================================== */} + + Admin: Mandanten (TODO)} /> + Admin: Benutzer (TODO)} /> + Admin: Globale Rollen (TODO)} /> + + - {/* Catch-all redirect to home */} + {/* ================================================== */} + {/* CATCH-ALL - Redirect to Dashboard */} + {/* ================================================== */} - - - - - + } /> @@ -77,4 +136,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/api/featuresApi.ts b/src/api/featuresApi.ts new file mode 100644 index 0000000..2ea691f --- /dev/null +++ b/src/api/featuresApi.ts @@ -0,0 +1,233 @@ +/** + * Features API + * + * API-Schicht für das Multi-Tenant Feature-System. + * Hauptendpoint: GET /features/my - Lädt alle Mandate + Features + Instanzen + Permissions + */ + +import api from '../api'; +import type { + FeaturesMyResponse, + Mandate, + MandateFeature, + FeatureInstance, + InstancePermissions, + AccessLevel, +} from '../types/mandate'; + +// ============================================================================= +// MOCK DATA (Temporär bis Backend bereit) +// ============================================================================= + +const MOCK_PERMISSIONS: InstancePermissions = { + tables: { + TrusteeOrganisation: { view: true, read: 'g', create: 'g', update: 'g', delete: 'n' }, + TrusteeContract: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' }, + TrusteeDocument: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' }, + TrusteePosition: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' }, + }, + views: { + 'trustee-dashboard': true, + 'trustee-organisations': true, + 'trustee-contracts': true, + 'trustee-documents': true, + 'trustee-positions': true, + 'trustee-roles': true, + 'trustee-access': true, + }, +}; + +const MOCK_CUSTOMER_PERMISSIONS: InstancePermissions = { + tables: { + TrusteeOrganisation: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' }, + TrusteeContract: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' }, + TrusteeDocument: { view: true, read: 'm', create: 'm', update: 'm', delete: 'n' }, + TrusteePosition: { view: true, read: 'm', create: 'n', update: 'n', delete: 'n' }, + }, + views: { + 'trustee-dashboard': true, + 'trustee-contracts': true, + 'trustee-documents': true, + 'trustee-positions': true, + 'trustee-organisations': false, + 'trustee-roles': false, + 'trustee-access': false, + }, +}; + +const MOCK_WORKFLOW_PERMISSIONS: InstancePermissions = { + tables: { + WorkflowRun: { view: true, read: 'g', create: 'g', update: 'm', delete: 'n' }, + WorkflowFile: { view: true, read: 'g', create: 'g', update: 'm', delete: 'm' }, + }, + views: { + 'chatworkflow-dashboard': true, + 'chatworkflow-runs': true, + 'chatworkflow-files': true, + }, +}; + +const MOCK_RESPONSE: FeaturesMyResponse = { + mandates: [ + { + id: 'mand-soha', + name: 'Soha Treuhand', + code: 'soha', + features: [ + { + code: 'trustee', + label: { de: 'Treuhand', en: 'Trustee' }, + icon: 'briefcase', + instances: [ + { + id: 'inst-soha-pamo', + featureCode: 'trustee', + mandateId: 'mand-soha', + mandateName: 'Soha Treuhand', + instanceLabel: 'PamoCreate AG', + userRole: 'admin', + permissions: MOCK_PERMISSIONS, + }, + { + id: 'inst-soha-valueon', + featureCode: 'trustee', + mandateId: 'mand-soha', + mandateName: 'Soha Treuhand', + instanceLabel: 'ValueOn AG', + userRole: 'customer', + permissions: MOCK_CUSTOMER_PERMISSIONS, + }, + ], + }, + { + code: 'chatworkflow', + label: { de: 'Workflow', en: 'Workflow' }, + icon: 'play_circle', + instances: [ + { + id: 'inst-soha-workflow', + featureCode: 'chatworkflow', + mandateId: 'mand-soha', + mandateName: 'Soha Treuhand', + instanceLabel: 'Beratung Dynamic', + userRole: 'user', + permissions: MOCK_WORKFLOW_PERMISSIONS, + }, + ], + }, + ], + }, + { + id: 'mand-swiss', + name: 'SwissTreu', + code: 'swisstreu', + features: [ + { + code: 'trustee', + label: { de: 'Treuhand', en: 'Trustee' }, + icon: 'briefcase', + instances: [ + { + id: 'inst-swiss-firma-x', + featureCode: 'trustee', + mandateId: 'mand-swiss', + mandateName: 'SwissTreu', + instanceLabel: 'Firma X', + userRole: 'customer', + permissions: MOCK_CUSTOMER_PERMISSIONS, + }, + ], + }, + ], + }, + ], +}; + +// Flag für Mock-Modus (auf false setzen wenn Backend bereit) +const USE_MOCK = true; + +// ============================================================================= +// API FUNCTIONS +// ============================================================================= + +/** + * Lädt alle Mandate + Features + Instanzen + Permissions für den aktuellen User + * + * Endpoint: GET /api/features/my + * + * Response enthält: + * - Alle Mandanten zu denen der User Zugriff hat + * - Pro Mandant: Alle Features mit deren Instanzen + * - Pro Instanz: Summarische Berechtigungen (tables, views) + */ +export async function fetchMyFeatures(): Promise { + if (USE_MOCK) { + console.log('📦 featuresApi: Using MOCK data'); + // Simuliere Netzwerk-Latenz + await new Promise(resolve => setTimeout(resolve, 300)); + return MOCK_RESPONSE; + } + + try { + console.log('📡 featuresApi: Fetching /api/features/my'); + const response = await api.get('/api/features/my'); + console.log('✅ featuresApi: Loaded features:', { + mandateCount: response.data.mandates.length, + totalInstances: response.data.mandates + .flatMap(m => m.features) + .flatMap(f => f.instances) + .length, + }); + return response.data; + } catch (error) { + console.error('❌ featuresApi: Error fetching features:', error); + throw error; + } +} + +/** + * Lädt die verfügbaren Features (für Admin - Feature-Instanz erstellen) + * + * Endpoint: GET /api/features/available + */ +export async function fetchAvailableFeatures(): Promise { + if (USE_MOCK) { + return [ + { code: 'trustee', label: { de: 'Treuhand', en: 'Trustee' }, icon: 'briefcase', instances: [] }, + { code: 'chatworkflow', label: { de: 'Workflow', en: 'Workflow' }, icon: 'play_circle', instances: [] }, + { code: 'chatbot', label: { de: 'Chatbot', en: 'Chatbot' }, icon: 'chat', instances: [] }, + ]; + } + + const response = await api.get('/api/features/available'); + return response.data; +} + +// ============================================================================= +// TYPE GUARDS +// ============================================================================= + +export function isValidAccessLevel(value: string): value is AccessLevel { + return ['n', 'm', 'g', 'a'].includes(value); +} + +export function isValidMandate(obj: unknown): obj is Mandate { + if (!obj || typeof obj !== 'object') return false; + const mandate = obj as Record; + return ( + typeof mandate.id === 'string' && + typeof mandate.name === 'string' && + Array.isArray(mandate.features) + ); +} + +export function isValidFeatureInstance(obj: unknown): obj is FeatureInstance { + if (!obj || typeof obj !== 'object') return false; + const instance = obj as Record; + return ( + typeof instance.id === 'string' && + typeof instance.featureCode === 'string' && + typeof instance.mandateId === 'string' && + typeof instance.instanceLabel === 'string' + ); +} diff --git a/src/components/Navigation/MandateNavigation.module.css b/src/components/Navigation/MandateNavigation.module.css new file mode 100644 index 0000000..150f65c --- /dev/null +++ b/src/components/Navigation/MandateNavigation.module.css @@ -0,0 +1,352 @@ +/** + * MandateNavigation Styles + * + * Hierarchische Navigation: + * System → Mandant → Feature → Instanz → Views + */ + +.navigation { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0 0.5rem; +} + +/* Separator */ +.separator { + height: 1px; + background: var(--border-color, #e0e0e0); + margin: 0.75rem 0.5rem; +} + +/* Section (System, Admin) */ +.section { + margin-bottom: 0.5rem; +} + +.sectionHeader { + padding: 0.5rem 0.75rem; +} + +.sectionTitle { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.1em; + color: var(--text-tertiary, #888); + text-transform: uppercase; +} + +.sectionContent { + display: flex; + flex-direction: column; + gap: 2px; +} + +/* Nav Item (Links) */ +.navItem { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-radius: 6px; + color: var(--text-secondary, #666); + text-decoration: none; + font-size: 0.875rem; + transition: all 0.15s ease; +} + +.navItem:hover { + background: var(--hover-bg, rgba(0, 0, 0, 0.04)); + color: var(--text-primary, #1a1a1a); +} + +.navItem.active { + background: var(--primary-light, #e0e7ff); + color: var(--primary-color, #2563eb); + font-weight: 500; +} + +.navIcon { + font-size: 1rem; + flex-shrink: 0; +} + +/* Mandate Group */ +.mandateGroup { + margin-bottom: 0.25rem; +} + +.mandateHeader { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem 0.75rem; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + transition: background 0.15s ease; +} + +.mandateHeader:hover { + background: var(--hover-bg, rgba(0, 0, 0, 0.04)); +} + +.mandateLabel { + flex: 1; + text-align: left; +} + +.mandateContent { + margin-left: 0.25rem; + padding-left: 0.75rem; + border-left: 2px solid var(--border-color, #e0e0e0); +} + +.activeMandate > .mandateContent { + border-left-color: var(--primary-color, #2563eb); +} + +/* Feature Group */ +.featureGroup { + margin-bottom: 0.25rem; +} + +.featureHeader { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 0.8125rem; + color: var(--text-secondary, #666); + transition: background 0.15s ease; +} + +.featureHeader:hover { + background: var(--hover-bg, rgba(0, 0, 0, 0.04)); +} + +.featureIcon { + display: flex; + align-items: center; + font-size: 0.875rem; +} + +.featureLabel { + flex: 1; + text-align: left; + font-weight: 500; +} + +.instanceCount { + font-size: 0.6875rem; + padding: 0.125rem 0.375rem; + background: var(--surface-color, #f0f0f0); + border-radius: 9999px; + color: var(--text-tertiary, #888); +} + +.featureContent { + margin-left: 0.25rem; + padding-left: 0.75rem; +} + +.activeFeature > .featureHeader { + color: var(--primary-color, #2563eb); +} + +/* Instance Group */ +.instanceGroup { + margin-bottom: 0.125rem; +} + +.instanceHeader { + display: flex; + align-items: center; + gap: 0.375rem; + width: 100%; + padding: 0.375rem 0.5rem; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 0.75rem; + color: var(--text-secondary, #666); + transition: background 0.15s ease; +} + +.instanceHeader:hover { + background: var(--hover-bg, rgba(0, 0, 0, 0.04)); +} + +.instanceLabel { + flex: 1; + text-align: left; + font-weight: 500; +} + +.roleBadge { + font-size: 0.625rem; + padding: 0.0625rem 0.375rem; + background: var(--surface-color, #f0f0f0); + border-radius: 9999px; + color: var(--text-tertiary, #888); + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.instanceViews { + margin-left: 0.25rem; + padding-left: 1rem; +} + +.activeInstance > .instanceHeader { + color: var(--primary-color, #2563eb); + background: var(--primary-light, #e0e7ff); +} + +.activeInstance .roleBadge { + background: var(--primary-color, #2563eb); + color: white; +} + +/* View Item */ +.viewItem { + display: block; + padding: 0.375rem 0.5rem; + border-radius: 4px; + color: var(--text-secondary, #666); + text-decoration: none; + font-size: 0.75rem; + transition: all 0.15s ease; +} + +.viewItem:hover { + background: var(--hover-bg, rgba(0, 0, 0, 0.04)); + color: var(--text-primary, #1a1a1a); +} + +.viewItem.active { + background: var(--primary-light, #e0e7ff); + color: var(--primary-color, #2563eb); + font-weight: 500; +} + +/* Chevron */ +.chevron { + font-size: 0.625rem; + color: var(--text-tertiary, #888); + flex-shrink: 0; +} + +/* Empty State */ +.emptyState { + padding: 1.5rem 1rem; + text-align: center; + color: var(--text-secondary, #666); + font-size: 0.875rem; +} + +.emptyHint { + font-size: 0.75rem; + color: var(--text-tertiary, #888); + margin-top: 0.5rem; +} + +/* Dark Theme */ +:global(.dark-theme) .separator { + background: var(--border-dark, #333); +} + +:global(.dark-theme) .sectionTitle { + color: var(--text-tertiary-dark, #666); +} + +:global(.dark-theme) .navItem { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .navItem:hover { + background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06)); + color: var(--text-primary-dark, #fff); +} + +:global(.dark-theme) .navItem.active { + background: var(--primary-dark-bg, #1e3a5f); + color: var(--primary-light, #93c5fd); +} + +:global(.dark-theme) .mandateHeader { + color: var(--text-primary-dark, #fff); +} + +:global(.dark-theme) .mandateHeader:hover { + background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06)); +} + +:global(.dark-theme) .mandateContent { + border-left-color: var(--border-dark, #444); +} + +:global(.dark-theme) .activeMandate > .mandateContent { + border-left-color: var(--primary-light, #93c5fd); +} + +:global(.dark-theme) .featureHeader { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .featureHeader:hover { + background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06)); +} + +:global(.dark-theme) .activeFeature > .featureHeader { + color: var(--primary-light, #93c5fd); +} + +:global(.dark-theme) .instanceCount, +:global(.dark-theme) .roleBadge { + background: var(--surface-dark, #2a2a2a); + color: var(--text-tertiary-dark, #888); +} + +:global(.dark-theme) .instanceHeader { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .instanceHeader:hover { + background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06)); +} + +:global(.dark-theme) .activeInstance > .instanceHeader { + color: var(--primary-light, #93c5fd); + background: var(--primary-dark-bg, #1e3a5f); +} + +:global(.dark-theme) .activeInstance .roleBadge { + background: var(--primary-color, #2563eb); + color: white; +} + +:global(.dark-theme) .viewItem { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .viewItem:hover { + background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06)); + color: var(--text-primary-dark, #fff); +} + +:global(.dark-theme) .viewItem.active { + background: var(--primary-dark-bg, #1e3a5f); + color: var(--primary-light, #93c5fd); +} diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx new file mode 100644 index 0000000..2986e70 --- /dev/null +++ b/src/components/Navigation/MandateNavigation.tsx @@ -0,0 +1,347 @@ +/** + * MandateNavigation + * + * Hierarchische Navigation für das Multi-Tenant-System. + * + * Struktur: + * - SYSTEM (immer verfügbar) + * - Mandant 1 + * - Feature A + * - Instanz 1 (mit Views) + * - Instanz 2 (mit Views) + * - Feature B + * - Instanz 3 (mit Views) + * - Mandant 2 + * - ... + * - ADMINISTRATION (nur für SysAdmin) + */ + +import React, { useState } from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; +import { useMandates, useFeatureStore } from '../../stores/featureStore'; +import { FEATURE_REGISTRY, getLabel } from '../../types/mandate'; +import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate'; +import { FaHome, FaCog, FaChevronDown, FaChevronRight, FaBriefcase, FaRobot, FaPlay } from 'react-icons/fa'; +import { RiAdminFill } from 'react-icons/ri'; +import styles from './MandateNavigation.module.css'; + +// ============================================================================= +// ICON MAPPING +// ============================================================================= + +const FEATURE_ICONS: Record = { + trustee: , + chatbot: , + chatworkflow: , +}; + +// ============================================================================= +// SYSTEM SECTION +// ============================================================================= + +const SystemSection: React.FC = () => { + const location = useLocation(); + + return ( +
+
+ SYSTEM +
+
+ + `${styles.navItem} ${isActive && location.pathname === '/' ? styles.active : ''}` + } + > + + Übersicht + + + `${styles.navItem} ${isActive ? styles.active : ''}` + } + > + + Einstellungen + +
+
+ ); +}; + +// ============================================================================= +// INSTANCE NAV GROUP +// ============================================================================= + +interface InstanceNavGroupProps { + instance: FeatureInstance; + mandateId: string; + featureCode: string; +} + +const InstanceNavGroup: React.FC = ({ + instance, + mandateId, + featureCode, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const location = useLocation(); + + // Prüfe ob wir in dieser Instanz sind + const basePath = `/mandates/${mandateId}/${featureCode}/${instance.id}`; + const isInInstance = location.pathname.startsWith(basePath); + + // Auto-expand wenn wir in der Instanz sind + React.useEffect(() => { + if (isInInstance && !isExpanded) { + setIsExpanded(true); + } + }, [isInInstance]); + + // Views aus Registry holen + const featureConfig = FEATURE_REGISTRY[featureCode]; + const views = featureConfig?.views || []; + + // Nur Views anzeigen für die der User Berechtigung hat + const visibleViews = views.filter(view => { + const viewCode = `${featureCode}-${view.code}`; + return instance.permissions?.views?.[viewCode] !== false; + }); + + return ( +
+ + + {isExpanded && ( +
+ {visibleViews.map(view => ( + + `${styles.viewItem} ${isActive ? styles.active : ''}` + } + > + {getLabel(view.label)} + + ))} +
+ )} +
+ ); +}; + +// ============================================================================= +// FEATURE NAV GROUP +// ============================================================================= + +interface FeatureNavGroupProps { + feature: MandateFeature; + mandateId: string; +} + +const FeatureNavGroup: React.FC = ({ feature, mandateId }) => { + const [isExpanded, setIsExpanded] = useState(false); + const location = useLocation(); + + // Prüfe ob wir in diesem Feature sind + const featurePath = `/mandates/${mandateId}/${feature.code}`; + const isInFeature = location.pathname.startsWith(featurePath); + + // Auto-expand wenn wir im Feature sind + React.useEffect(() => { + if (isInFeature && !isExpanded) { + setIsExpanded(true); + } + }, [isInFeature]); + + if (feature.instances.length === 0) { + return null; + } + + return ( +
+ + + {isExpanded && ( +
+ {feature.instances.map(instance => ( + + ))} +
+ )} +
+ ); +}; + +// ============================================================================= +// MANDATE NAV GROUP +// ============================================================================= + +interface MandateNavGroupProps { + mandate: Mandate; +} + +const MandateNavGroup: React.FC = ({ mandate }) => { + const [isExpanded, setIsExpanded] = useState(true); + const location = useLocation(); + + // Prüfe ob wir in diesem Mandanten sind + const mandatePath = `/mandates/${mandate.id}`; + const isInMandate = location.pathname.startsWith(mandatePath); + + if (mandate.features.length === 0) { + return null; + } + + return ( +
+ + + {isExpanded && ( +
+ {mandate.features.map(feature => ( + + ))} +
+ )} +
+ ); +}; + +// ============================================================================= +// ADMIN SECTION +// ============================================================================= + +interface AdminSectionProps { + isSysAdmin: boolean; +} + +const AdminSection: React.FC = ({ isSysAdmin }) => { + if (!isSysAdmin) { + return null; + } + + return ( +
+
+ ADMINISTRATION +
+
+ + `${styles.navItem} ${isActive ? styles.active : ''}` + } + > + + Mandanten + + + `${styles.navItem} ${isActive ? styles.active : ''}` + } + > + + Benutzer + + + `${styles.navItem} ${isActive ? styles.active : ''}` + } + > + + Globale Rollen + +
+
+ ); +}; + +// ============================================================================= +// EMPTY STATE +// ============================================================================= + +const EmptyState: React.FC = () => ( +
+

Keine Feature-Instanzen verfügbar.

+

+ Kontaktiere einen Administrator, um Zugriff zu erhalten. +

+
+); + +// ============================================================================= +// MAIN COMPONENT +// ============================================================================= + +export const MandateNavigation: React.FC = () => { + const mandates = useMandates(); + const { hasAnyInstance } = useFeatureStore(); + + // TODO: Aus Auth-Store holen + const isSysAdmin = false; + + return ( +
+ {/* System-Bereich (immer sichtbar) */} + + + {/* Separator */} +
+ + {/* Mandanten & Features */} + {hasAnyInstance() ? ( + mandates.map(mandate => ( + + )) + ) : ( + + )} + + {/* Separator vor Admin */} + {isSysAdmin &&
} + + {/* Admin-Bereich (nur für SysAdmin) */} + +
+ ); +}; + +export default MandateNavigation; diff --git a/src/components/Navigation/index.ts b/src/components/Navigation/index.ts new file mode 100644 index 0000000..f0d25c6 --- /dev/null +++ b/src/components/Navigation/index.ts @@ -0,0 +1,5 @@ +/** + * Navigation Components Export + */ + +export { MandateNavigation } from './MandateNavigation'; diff --git a/src/hooks/useCurrentInstance.ts b/src/hooks/useCurrentInstance.ts new file mode 100644 index 0000000..1efd0d6 --- /dev/null +++ b/src/hooks/useCurrentInstance.ts @@ -0,0 +1,137 @@ +/** + * useCurrentInstance Hook + * + * Liest die aktuelle Feature-Instanz aus den URL-Parametern. + * Die URL-Struktur ist: /mandates/:mandateId/:featureCode/:instanceId/... + * + * Dieser Hook ist die zentrale Stelle um den aktuellen Arbeitskontext zu ermitteln. + */ + +import { useParams } from 'react-router-dom'; +import { useFeatureStore } from '../stores/featureStore'; +import type { FeatureInstance, Mandate, MandateFeature } from '../types/mandate'; + +// ============================================================================= +// URL PARAMETER TYPES +// ============================================================================= + +export interface FeatureRouteParams { + mandateId?: string; + featureCode?: string; + instanceId?: string; + '*'?: string; // Wildcard für Sub-Pfade +} + +// ============================================================================= +// RETURN TYPES +// ============================================================================= + +export interface CurrentInstanceContext { + // Aus URL + mandateId: string | undefined; + featureCode: string | undefined; + instanceId: string | undefined; + + // Aufgelöste Objekte + mandate: Mandate | undefined; + feature: MandateFeature | undefined; + instance: FeatureInstance | undefined; + + // Hilfsfunktionen + isValid: boolean; + isLoading: boolean; +} + +// ============================================================================= +// HOOKS +// ============================================================================= + +/** + * Haupthook für den aktuellen Instanz-Kontext + * + * Verwendung: + * ```tsx + * function ContractList() { + * const { instance, isValid } = useCurrentInstance(); + * + * if (!isValid) { + * return ; + * } + * + * // Arbeite mit instance.permissions, etc. + * } + * ``` + */ +export function useCurrentInstance(): CurrentInstanceContext { + const params = useParams(); + const { getMandateById, getFeatureByCode, getInstanceById, loading } = useFeatureStore(); + + const mandateId = params.mandateId; + const featureCode = params.featureCode; + const instanceId = params.instanceId; + + // Objekte auflösen + const mandate = mandateId ? getMandateById(mandateId) : undefined; + const feature = mandateId && featureCode ? getFeatureByCode(mandateId, featureCode) : undefined; + const instance = instanceId ? getInstanceById(instanceId) : undefined; + + // Validierung: Alle drei müssen vorhanden und konsistent sein + const isValid = !!( + mandate && + feature && + instance && + instance.mandateId === mandateId && + instance.featureCode === featureCode + ); + + return { + mandateId, + featureCode, + instanceId, + mandate, + feature, + instance, + isValid, + isLoading: loading, + }; +} + +/** + * Vereinfachter Hook - gibt nur die Instanz zurück + */ +export function useInstance(): FeatureInstance | undefined { + const { instance } = useCurrentInstance(); + return instance; +} + +/** + * Hook für die Instanz-ID aus der URL + */ +export function useInstanceId(): string | undefined { + const params = useParams(); + return params.instanceId; +} + +/** + * Hook für den Feature-Code aus der URL + */ +export function useFeatureCode(): string | undefined { + const params = useParams(); + return params.featureCode; +} + +/** + * Hook für die Mandate-ID aus der URL + */ +export function useMandateId(): string | undefined { + const params = useParams(); + return params.mandateId; +} + +/** + * Hook der prüft ob wir in einem Feature-Kontext sind + */ +export function useIsInFeatureContext(): boolean { + const { isValid } = useCurrentInstance(); + return isValid; +} diff --git a/src/hooks/useInstancePermissions.ts b/src/hooks/useInstancePermissions.ts new file mode 100644 index 0000000..f16a219 --- /dev/null +++ b/src/hooks/useInstancePermissions.ts @@ -0,0 +1,299 @@ +/** + * Instance Permission Hooks + * + * Hooks für Berechtigungsprüfungen basierend auf der aktuellen Feature-Instanz. + * Die Berechtigungen werden summarisch pro Instanz geladen (kein einzelner API-Call pro Check). + */ + +import { useCallback, useMemo } from 'react'; +import { useCurrentInstance } from './useCurrentInstance'; +import type { + TablePermission, + FieldPermission, + AccessLevel, + InstancePermissions, +} from '../types/mandate'; +import { canAccessRecord, hasAccess } from '../types/mandate'; + +// ============================================================================= +// DEFAULT PERMISSIONS (Kein Zugriff) +// ============================================================================= + +const NO_ACCESS_TABLE: TablePermission = { + view: false, + read: 'n', + create: 'n', + update: 'n', + delete: 'n', +}; + +const NO_ACCESS_FIELD: FieldPermission = { + read: false, + write: false, +}; + +// ============================================================================= +// TABLE PERMISSION HOOKS +// ============================================================================= + +/** + * Hook für Tabellen-Berechtigungen + * + * Verwendung: + * ```tsx + * function ContractList() { + * const { canCreate, canUpdate, canDelete, read } = useTablePermission('TrusteeContract'); + * + * return ( + *
+ * {canCreate && } + * {contracts.map(c => ( + * + * {canUpdate(c) && } + * {canDelete(c) && } + * + * ))} + *
+ * ); + * } + * ``` + */ +export function useTablePermission(tableName: string) { + const { instance } = useCurrentInstance(); + + const permission = useMemo((): TablePermission => { + if (!instance?.permissions?.tables) { + return NO_ACCESS_TABLE; + } + return instance.permissions.tables[tableName] ?? NO_ACCESS_TABLE; + }, [instance, tableName]); + + // Kontext für Record-basierte Prüfungen + const userId = ''; // TODO: Aus Auth-Store holen + + return { + // Raw permission levels + view: permission.view, + read: permission.read, + create: permission.create, + update: permission.update, + delete: permission.delete, + + // Convenience Booleans + canView: permission.view, + canRead: hasAccess(permission.read), + canCreate: hasAccess(permission.create), + canUpdate: hasAccess(permission.update), + canDelete: hasAccess(permission.delete), + + // Record-basierte Prüfungen + canReadRecord: (record: { _createdBy?: string }) => + canAccessRecord(permission.read, record, userId), + canUpdateRecord: (record: { _createdBy?: string }) => + canAccessRecord(permission.update, record, userId), + canDeleteRecord: (record: { _createdBy?: string }) => + canAccessRecord(permission.delete, record, userId), + }; +} + +/** + * Vereinfachter Hook - prüft nur ob Tabelle sichtbar ist + */ +export function useCanViewTable(tableName: string): boolean { + const { canView } = useTablePermission(tableName); + return canView; +} + +// ============================================================================= +// VIEW PERMISSION HOOKS +// ============================================================================= + +/** + * Hook für View-Berechtigungen (Navigation) + * + * Verwendung: + * ```tsx + * function Navigation() { + * const canViewContracts = useCanViewFeatureView('trustee-contracts'); + * + * return ( + * + * ); + * } + * ``` + */ +export function useCanViewFeatureView(viewCode: string): boolean { + const { instance } = useCurrentInstance(); + + if (!instance?.permissions?.views) { + return false; + } + + return instance.permissions.views[viewCode] ?? false; +} + +/** + * Hook für mehrere View-Berechtigungen gleichzeitig + */ +export function useViewPermissions(viewCodes: string[]): Record { + const { instance } = useCurrentInstance(); + + return useMemo(() => { + const result: Record = {}; + + viewCodes.forEach(code => { + result[code] = instance?.permissions?.views?.[code] ?? false; + }); + + return result; + }, [instance, viewCodes]); +} + +// ============================================================================= +// FIELD PERMISSION HOOKS +// ============================================================================= + +/** + * Hook für Feld-Berechtigungen + * + * Verwendung: + * ```tsx + * function ContractForm() { + * const { canRead, canWrite } = useFieldPermission('TrusteeContract', 'salary'); + * + * return ( + *
+ * {canRead && ( + * + * )} + * + * ); + * } + * ``` + */ +export function useFieldPermission(tableName: string, fieldName: string): FieldPermission { + const { instance } = useCurrentInstance(); + + return useMemo(() => { + const fieldPermissions = instance?.permissions?.fields?.[tableName]; + if (!fieldPermissions) { + // Wenn keine Feld-Level Einschränkungen, erlaube alles + return { read: true, write: true }; + } + + return fieldPermissions[fieldName] ?? { read: true, write: true }; + }, [instance, tableName, fieldName]); +} + +// ============================================================================= +// GENERIC PERMISSION CHECK +// ============================================================================= + +/** + * Generischer Hook für beliebige Berechtigungsprüfungen + */ +export function useInstancePermissions(): InstancePermissions | undefined { + const { instance } = useCurrentInstance(); + return instance?.permissions; +} + +/** + * Hook der prüft ob ein Record bearbeitet werden darf + * Kombiniert Tabellen-Permission mit Record-Owner-Check + */ +export function useCanEditRecord( + tableName: string, + record: { _createdBy?: string } | undefined, + userId: string +): boolean { + const { update } = useTablePermission(tableName); + + if (!record) return false; + + return canAccessRecord(update, record, userId); +} + +/** + * Hook der prüft ob ein Record gelöscht werden darf + */ +export function useCanDeleteRecord( + tableName: string, + record: { _createdBy?: string } | undefined, + userId: string +): boolean { + const { delete: deleteLevel } = useTablePermission(tableName); + + if (!record) return false; + + return canAccessRecord(deleteLevel, record, userId); +} + +// ============================================================================= +// PERMISSION GATE COMPONENT +// ============================================================================= + +interface PermissionGateProps { + table?: string; + view?: string; + action?: 'view' | 'read' | 'create' | 'update' | 'delete'; + record?: { _createdBy?: string }; + children: React.ReactNode; + fallback?: React.ReactNode; +} + +/** + * Komponente für bedingte Anzeige basierend auf Berechtigungen + * + * Verwendung: + * ```tsx + * + * + * + * + * }> + * + * + * ``` + */ +export function PermissionGate({ + table, + view, + action = 'view', + record, + children, + fallback = null, +}: PermissionGateProps): React.ReactElement | null { + const { instance } = useCurrentInstance(); + const userId = ''; // TODO: Aus Auth-Store holen + + let hasPermission = false; + + if (view) { + // View-basierte Prüfung + hasPermission = instance?.permissions?.views?.[view] ?? false; + } else if (table) { + // Tabellen-basierte Prüfung + const tablePermission = instance?.permissions?.tables?.[table]; + + if (!tablePermission) { + hasPermission = false; + } else if (action === 'view') { + hasPermission = tablePermission.view; + } else { + const level = tablePermission[action] as AccessLevel; + + if (record) { + hasPermission = canAccessRecord(level, record, userId); + } else { + hasPermission = hasAccess(level); + } + } + } + + return hasPermission ? <>{children} : <>{fallback}; +} diff --git a/src/layouts/FeatureLayout.module.css b/src/layouts/FeatureLayout.module.css new file mode 100644 index 0000000..5cce883 --- /dev/null +++ b/src/layouts/FeatureLayout.module.css @@ -0,0 +1,174 @@ +/** + * FeatureLayout Styles + */ + +/* Loading Container */ +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 400px; + gap: 1rem; + color: var(--text-secondary, #666); +} + +.loadingSpinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color, #e0e0e0); + border-top-color: var(--primary-color, #2563eb); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Error Container */ +.errorContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 400px; + gap: 1rem; + padding: 2rem; + text-align: center; +} + +.errorIcon { + font-size: 3rem; +} + +.errorContainer h2 { + margin: 0; + color: var(--text-primary, #1a1a1a); + font-size: 1.5rem; + font-weight: 600; +} + +.errorContainer p { + margin: 0; + color: var(--text-secondary, #666); + max-width: 400px; +} + +.errorLink { + margin-top: 1rem; + padding: 0.75rem 1.5rem; + background: var(--primary-color, #2563eb); + color: white; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + transition: background 0.2s; +} + +.errorLink:hover { + background: var(--primary-hover, #1d4ed8); +} + +/* Feature Layout */ +.featureLayout { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +/* Feature Header */ +.featureHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--surface-color, #f8f9fa); + border-bottom: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--text-secondary, #666); +} + +.separator { + color: var(--border-color, #d0d0d0); +} + +.mandateName { + color: var(--text-tertiary, #888); +} + +.featureName { + color: var(--text-secondary, #666); + font-weight: 500; +} + +.instanceName { + color: var(--text-primary, #1a1a1a); + font-weight: 600; +} + +.roleIndicator { + display: flex; + align-items: center; +} + +.roleBadge { + padding: 0.25rem 0.75rem; + background: var(--primary-light, #e0e7ff); + color: var(--primary-color, #2563eb); + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +/* Feature Content */ +.featureContent { + flex: 1; + overflow: auto; + padding: 1.5rem; +} + +/* Dark Theme */ +:global(.dark-theme) .featureHeader { + background: var(--surface-dark, #1e1e1e); + border-bottom-color: var(--border-dark, #333); +} + +:global(.dark-theme) .mandateName { + color: var(--text-tertiary-dark, #888); +} + +:global(.dark-theme) .featureName { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .instanceName { + color: var(--text-primary-dark, #fff); +} + +:global(.dark-theme) .roleBadge { + background: var(--primary-dark-bg, #1e3a5f); + color: var(--primary-light, #93c5fd); +} + +:global(.dark-theme) .errorContainer h2 { + color: var(--text-primary-dark, #fff); +} + +:global(.dark-theme) .errorContainer p { + color: var(--text-secondary-dark, #aaa); +} diff --git a/src/layouts/FeatureLayout.tsx b/src/layouts/FeatureLayout.tsx new file mode 100644 index 0000000..406816b --- /dev/null +++ b/src/layouts/FeatureLayout.tsx @@ -0,0 +1,151 @@ +/** + * FeatureLayout + * + * Layout-Wrapper für Feature-Instanz-Seiten. + * Stellt den Instanz-Kontext bereit und rendert Sidebar + Content. + */ + +import React from 'react'; +import { Outlet, Navigate, useLocation } from 'react-router-dom'; +import { useCurrentInstance } from '../hooks/useCurrentInstance'; +import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore'; +import styles from './FeatureLayout.module.css'; + +// ============================================================================= +// LOADING COMPONENT +// ============================================================================= + +const LoadingScreen: React.FC = () => ( +
+
+

Lade Feature-Daten...

+
+); + +// ============================================================================= +// ERROR COMPONENT +// ============================================================================= + +interface ErrorScreenProps { + message: string; + returnPath?: string; +} + +const ErrorScreen: React.FC = ({ message, returnPath = '/' }) => ( +
+
⚠️
+

Zugriff nicht möglich

+

{message}

+ + Zurück zur Übersicht + +
+); + +// ============================================================================= +// FEATURE LAYOUT +// ============================================================================= + +/** + * FeatureLayout rendert den Inhalt einer Feature-Instanz. + * + * Prüft: + * 1. Ob Features geladen sind + * 2. Ob die Instanz existiert und gültig ist + * 3. Ob der User Zugriff hat + * + * Bei Erfolg: Rendert für die verschachtelten Routes + */ +export const FeatureLayout: React.FC = () => { + const location = useLocation(); + const initialized = useFeaturesInitialized(); + const loading = useFeaturesLoading(); + const { instance, mandate, feature, isValid, isLoading } = useCurrentInstance(); + + // Warten bis Features geladen sind + if (!initialized || loading || isLoading) { + return ; + } + + // Prüfen ob Instanz existiert und gültig ist + if (!isValid) { + console.warn('FeatureLayout: Invalid instance context', { + path: location.pathname, + hasMandate: !!mandate, + hasFeature: !!feature, + hasInstance: !!instance, + }); + + return ( + + ); + } + + // Alles OK - rendere Content + return ( +
+ {/* Header mit Instanz-Info */} +
+
+ {mandate?.name} + / + {feature?.label?.de || feature?.code} + / + {instance?.instanceLabel} +
+
+ {instance?.userRole} +
+
+ + {/* Content Area */} +
+ +
+
+ ); +}; + +// ============================================================================= +// PROTECTED FEATURE ROUTE +// ============================================================================= + +interface ProtectedFeatureRouteProps { + requiredView?: string; + children: React.ReactNode; +} + +/** + * Wrapper für geschützte Feature-Routes + * Prüft zusätzlich View-Berechtigungen + */ +export const ProtectedFeatureRoute: React.FC = ({ + requiredView, + children, +}) => { + const { instance, isValid } = useCurrentInstance(); + + if (!isValid) { + return ; + } + + // Prüfe View-Berechtigung wenn erforderlich + if (requiredView) { + const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false; + + if (!hasViewAccess) { + return ( + + ); + } + } + + return <>{children}; +}; + +export default FeatureLayout; diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css new file mode 100644 index 0000000..63c802b --- /dev/null +++ b/src/layouts/MainLayout.module.css @@ -0,0 +1,132 @@ +/** + * MainLayout Styles + */ + +.mainLayout { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; + background: var(--bg-primary, #ffffff); +} + +/* Sidebar */ +.sidebar { + display: flex; + flex-direction: column; + width: 280px; + min-width: 280px; + height: 100%; + background: var(--surface-color, #f8f9fa); + border-right: 1px solid var(--border-color, #e0e0e0); + overflow: hidden; +} + +/* Logo */ +.logoContainer { + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); +} + +.logoText { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.logoPower { + color: var(--text-primary, #1a1a1a); +} + +.logoOn { + color: var(--primary-color, #2563eb); +} + +/* Navigation */ +.navigation { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0.5rem 0; +} + +.loadingNav, +.errorNav { + padding: 1rem; + text-align: center; + color: var(--text-secondary, #666); + font-size: 0.875rem; +} + +.errorNav { + color: var(--error-color, #dc2626); +} + +/* User Section */ +.userSection { + padding: 1rem; + border-top: 1px solid var(--border-color, #e0e0e0); + flex-shrink: 0; +} + +/* Content */ +.content { + flex: 1; + overflow: auto; + background: var(--bg-primary, #ffffff); +} + +/* Dark Theme */ +:global(.dark-theme) .mainLayout { + background: var(--bg-dark, #0a0a0a); +} + +:global(.dark-theme) .sidebar { + background: var(--surface-dark, #1a1a1a); + border-right-color: var(--border-dark, #333); +} + +:global(.dark-theme) .logoContainer { + border-bottom-color: var(--border-dark, #333); +} + +:global(.dark-theme) .logoPower { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .userSection { + border-top-color: var(--border-dark, #333); +} + +:global(.dark-theme) .content { + background: var(--bg-dark, #0a0a0a); +} + +/* Scrollbar Styling */ +.navigation::-webkit-scrollbar { + width: 6px; +} + +.navigation::-webkit-scrollbar-track { + background: transparent; +} + +.navigation::-webkit-scrollbar-thumb { + background: var(--border-color, #d0d0d0); + border-radius: 3px; +} + +.navigation::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary, #888); +} + +:global(.dark-theme) .navigation::-webkit-scrollbar-thumb { + background: var(--border-dark, #444); +} + +:global(.dark-theme) .navigation::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary-dark, #666); +} diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..42fdbe6 --- /dev/null +++ b/src/layouts/MainLayout.tsx @@ -0,0 +1,83 @@ +/** + * MainLayout + * + * Hauptlayout der Anwendung mit Sidebar und Content-Bereich. + * Enthält den FeatureProvider für das Multi-Tenant-System. + */ + +import React, { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; +import { MandateNavigation } from '../components/Navigation/MandateNavigation'; +import styles from './MainLayout.module.css'; + +// ============================================================================= +// INNER LAYOUT (mit Zugriff auf Store) +// ============================================================================= + +const MainLayoutInner: React.FC = () => { + const { loadFeatures, initialized, loading, error } = useFeatureStore(); + + // Features laden beim Mount + useEffect(() => { + if (!initialized && !loading) { + loadFeatures(); + } + }, [initialized, loading, loadFeatures]); + + return ( +
+ {/* Sidebar */} + + + {/* Content */} +
+ +
+
+ ); +}; + +// ============================================================================= +// MAIN LAYOUT (mit Provider) +// ============================================================================= + +export const MainLayout: React.FC = () => { + return ( + + + + ); +}; + +export default MainLayout; diff --git a/src/layouts/index.ts b/src/layouts/index.ts new file mode 100644 index 0000000..ec8f5b7 --- /dev/null +++ b/src/layouts/index.ts @@ -0,0 +1,6 @@ +/** + * Layouts Export + */ + +export { MainLayout } from './MainLayout'; +export { FeatureLayout, ProtectedFeatureRoute } from './FeatureLayout'; diff --git a/src/pages/Dashboard.module.css b/src/pages/Dashboard.module.css new file mode 100644 index 0000000..f5dc49a --- /dev/null +++ b/src/pages/Dashboard.module.css @@ -0,0 +1,247 @@ +/** + * Dashboard Page Styles + */ + +.dashboard { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +/* Header */ +.header { + margin-bottom: 2rem; +} + +.header h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary, #1a1a1a); +} + +.subtitle { + margin: 0.5rem 0 0; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +/* Content */ +.content { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Feature Section */ +.featureSection { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.sectionTitle { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +/* Instance Grid */ +.instanceGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; +} + +/* Instance Card */ +.instanceCard { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 12px; + text-decoration: none; + transition: all 0.2s ease; +} + +.instanceCard:hover { + border-color: var(--primary-color, #2563eb); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} + +.cardIcon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 10px; + background: var(--primary-light, #e0e7ff); + color: var(--primary-color, #2563eb); + flex-shrink: 0; +} + +.cardContent { + flex: 1; + min-width: 0; +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.featureLabel { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-tertiary, #888); + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.roleBadge { + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + background: var(--surface-color, #f0f0f0); + border-radius: 9999px; + color: var(--text-tertiary, #888); + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.instanceLabel { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mandateName { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + color: var(--text-secondary, #666); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cardArrow { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--surface-color, #f5f5f5); + color: var(--text-tertiary, #888); + flex-shrink: 0; + transition: all 0.2s ease; +} + +.instanceCard:hover .cardArrow { + background: var(--primary-color, #2563eb); + color: white; +} + +/* Empty State */ +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + padding: 2rem; + text-align: center; +} + +.emptyIcon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.emptyState h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.emptyState p { + margin: 0.5rem 0 0; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +/* Dark Theme */ +:global(.dark-theme) .header h1 { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .subtitle { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .sectionTitle { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .instanceCard { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .instanceCard:hover { + border-color: var(--primary-light, #93c5fd); +} + +:global(.dark-theme) .cardIcon { + background: var(--primary-dark-bg, #1e3a5f); + color: var(--primary-light, #93c5fd); +} + +:global(.dark-theme) .featureLabel { + color: var(--text-tertiary-dark, #888); +} + +:global(.dark-theme) .roleBadge { + background: var(--surface-dark, #2a2a2a); + color: var(--text-tertiary-dark, #888); +} + +:global(.dark-theme) .instanceLabel { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .mandateName { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .cardArrow { + background: var(--surface-dark, #2a2a2a); + color: var(--text-tertiary-dark, #888); +} + +:global(.dark-theme) .instanceCard:hover .cardArrow { + background: var(--primary-color, #2563eb); + color: white; +} + +:global(.dark-theme) .emptyState h2 { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .emptyState p { + color: var(--text-secondary-dark, #aaa); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..df54211 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,139 @@ +/** + * Dashboard Page + * + * System-Übersicht für den User. + * Zeigt alle verfügbaren Feature-Instanzen als Karten an. + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useMandates, useFeatureStore } from '../stores/featureStore'; +import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; +import type { FeatureInstance } from '../types/mandate'; +import { FaBriefcase, FaRobot, FaPlay, FaArrowRight } from 'react-icons/fa'; +import styles from './Dashboard.module.css'; + +// ============================================================================= +// FEATURE ICONS +// ============================================================================= + +const FEATURE_ICONS: Record = { + trustee: , + chatbot: , + chatworkflow: , +}; + +// ============================================================================= +// INSTANCE CARD +// ============================================================================= + +interface InstanceCardProps { + instance: FeatureInstance; + featureLabel: string; +} + +const InstanceCard: React.FC = ({ instance, featureLabel }) => { + const basePath = `/mandates/${instance.mandateId}/${instance.featureCode}/${instance.id}`; + + // Ersten verfügbaren View finden + const featureConfig = FEATURE_REGISTRY[instance.featureCode]; + const firstView = featureConfig?.views?.[0]; + const targetPath = firstView ? `${basePath}/${firstView.path}` : basePath; + + return ( + +
+ {FEATURE_ICONS[instance.featureCode] || } +
+
+
+ {featureLabel} + {instance.userRole} +
+

{instance.instanceLabel}

+

{instance.mandateName}

+
+
+ +
+ + ); +}; + +// ============================================================================= +// EMPTY STATE +// ============================================================================= + +const EmptyState: React.FC = () => ( +
+
📋
+

Willkommen bei PowerOn

+

Du hast aktuell Zugriff auf keine Feature-Instanzen.

+

Kontaktiere einen Administrator, um Zugriff zu erhalten.

+
+); + +// ============================================================================= +// DASHBOARD PAGE +// ============================================================================= + +export const DashboardPage: React.FC = () => { + const mandates = useMandates(); + const { hasAnyInstance, getAllInstances } = useFeatureStore(); + + // Alle Instanzen sammeln für Übersicht + const allInstances = getAllInstances(); + + // Gruppiere nach Feature + const instancesByFeature = allInstances.reduce((acc, instance) => { + const featureCode = instance.featureCode; + if (!acc[featureCode]) { + acc[featureCode] = []; + } + acc[featureCode].push(instance); + return acc; + }, {} as Record); + + if (!hasAnyInstance()) { + return ; + } + + return ( +
+
+

Übersicht

+

+ Du hast Zugriff auf {allInstances.length} Feature-Instanz{allInstances.length !== 1 ? 'en' : ''} + in {mandates.length} Mandant{mandates.length !== 1 ? 'en' : ''}. +

+
+ +
+ {Object.entries(instancesByFeature).map(([featureCode, instances]) => { + const featureConfig = FEATURE_REGISTRY[featureCode]; + const featureLabel = featureConfig ? getLabel(featureConfig.label) : featureCode; + + return ( +
+

+ {FEATURE_ICONS[featureCode]} + {featureLabel} +

+
+ {instances.map(instance => ( + + ))} +
+
+ ); + })} +
+
+ ); +}; + +export default DashboardPage; diff --git a/src/pages/FeatureView.module.css b/src/pages/FeatureView.module.css new file mode 100644 index 0000000..9bbc145 --- /dev/null +++ b/src/pages/FeatureView.module.css @@ -0,0 +1,122 @@ +/** + * FeatureView Page Styles + */ + +.featureView { + display: flex; + flex-direction: column; + height: 100%; +} + +/* View Header */ +.viewHeader { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-primary, #ffffff); +} + +.viewTitle { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +/* View Content */ +.viewContent { + flex: 1; + overflow: auto; + padding: 1.5rem; +} + +/* Placeholder */ +.placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + padding: 2rem; + background: var(--surface-color, #f8f9fa); + border: 2px dashed var(--border-color, #e0e0e0); + border-radius: 12px; + text-align: center; +} + +.placeholder h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.placeholder p { + margin: 0.5rem 0 0; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +/* Not Found */ +.notFound, +.accessDenied { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + padding: 2rem; + text-align: center; +} + +.notFound h2, +.accessDenied h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.notFound p, +.accessDenied p { + margin: 0.5rem 0 0; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +.accessDenied { + background: var(--error-light, #fef2f2); + border-radius: 12px; +} + +.accessDenied h2 { + color: var(--error-color, #dc2626); +} + +/* Dark Theme */ +:global(.dark-theme) .viewHeader { + background: var(--surface-dark, #1a1a1a); + border-bottom-color: var(--border-dark, #333); +} + +:global(.dark-theme) .viewTitle { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .placeholder { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #444); +} + +:global(.dark-theme) .placeholder h2, +:global(.dark-theme) .notFound h2 { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .placeholder p, +:global(.dark-theme) .notFound p { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .accessDenied { + background: rgba(220, 38, 38, 0.1); +} diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx new file mode 100644 index 0000000..99488d7 --- /dev/null +++ b/src/pages/FeatureView.tsx @@ -0,0 +1,202 @@ +/** + * FeatureView Page + * + * Generische Feature-View-Komponente. + * Rendert den entsprechenden Content basierend auf Feature-Code und View. + * + * Die Komponente ist Feature-agnostisch und delegiert an spezifische View-Komponenten. + */ + +import React from 'react'; +import { useCurrentInstance } from '../hooks/useCurrentInstance'; +import { useCanViewFeatureView } from '../hooks/useInstancePermissions'; +import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; +import styles from './FeatureView.module.css'; + +// ============================================================================= +// VIEW COMPONENTS (Placeholders - werden später durch echte ersetzt) +// ============================================================================= + +// Trustee Views +const TrusteeDashboard: React.FC = () => ( +
+

Trustee Dashboard

+

Übersicht der Treuhand-Aktivitäten

+
+); + +const TrusteeOrganisations: React.FC = () => ( +
+

Organisationen

+

Verwaltung der Organisationen

+
+); + +const TrusteeContracts: React.FC = () => ( +
+

Verträge

+

Vertragsverwaltung

+
+); + +const TrusteeDocuments: React.FC = () => ( +
+

Dokumente

+

Dokumentenverwaltung

+
+); + +const TrusteePositions: React.FC = () => ( +
+

Positionen

+

Positionsverwaltung

+
+); + +const TrusteeRoles: React.FC = () => ( +
+

Rollen

+

Rollenverwaltung

+
+); + +const TrusteeAccess: React.FC = () => ( +
+

Zugriffe

+

Zugriffsverwaltung

+
+); + +// Chatworkflow Views +const ChatworkflowDashboard: React.FC = () => ( +
+

Workflow Dashboard

+

Übersicht der Workflows

+
+); + +const ChatworkflowRuns: React.FC = () => ( +
+

Runs

+

Workflow-Ausführungen

+
+); + +const ChatworkflowFiles: React.FC = () => ( +
+

Dateien

+

Workflow-Dateien

+
+); + +// Chatbot Views +const ChatbotConversations: React.FC = () => ( +
+

Konversationen

+

Chat-Konversationen

+
+); + +const ChatbotSettings: React.FC = () => ( +
+

Chatbot Einstellungen

+

Konfiguration des Chatbots

+
+); + +// Generic/Fallback +const NotFound: React.FC = () => ( +
+

Seite nicht gefunden

+

Diese View existiert nicht oder wurde noch nicht implementiert.

+
+); + +const AccessDenied: React.FC = () => ( +
+

Zugriff verweigert

+

Du hast keine Berechtigung für diese Ansicht.

+
+); + +// ============================================================================= +// VIEW REGISTRY +// ============================================================================= + +type ViewComponent = React.FC; + +const VIEW_COMPONENTS: Record> = { + trustee: { + dashboard: TrusteeDashboard, + organisations: TrusteeOrganisations, + contracts: TrusteeContracts, + documents: TrusteeDocuments, + positions: TrusteePositions, + roles: TrusteeRoles, + access: TrusteeAccess, + }, + chatworkflow: { + dashboard: ChatworkflowDashboard, + runs: ChatworkflowRuns, + files: ChatworkflowFiles, + }, + chatbot: { + conversations: ChatbotConversations, + settings: ChatbotSettings, + }, +}; + +// ============================================================================= +// FEATURE VIEW PAGE +// ============================================================================= + +interface FeatureViewPageProps { + view: string; +} + +export const FeatureViewPage: React.FC = ({ view }) => { + const { instance, featureCode, isValid } = useCurrentInstance(); + + // Berechtigungs-Check + const viewCode = `${featureCode}-${view}`; + const canView = useCanViewFeatureView(viewCode); + + // Nicht valider Kontext + if (!isValid || !featureCode || !instance) { + return ; + } + + // Keine Berechtigung + if (!canView && view !== 'not-found') { + return ; + } + + // View-Komponente finden + const featureViews = VIEW_COMPONENTS[featureCode]; + if (!featureViews) { + return ; + } + + const ViewComponent = featureViews[view]; + if (!ViewComponent) { + return ; + } + + // View-Info aus Registry + const featureConfig = FEATURE_REGISTRY[featureCode]; + const viewConfig = featureConfig?.views?.find(v => v.code === view); + const viewLabel = viewConfig ? getLabel(viewConfig.label) : view; + + return ( +
+
+

{viewLabel}

+
+
+ +
+
+ ); +}; + +export default FeatureViewPage; diff --git a/src/pages/Settings.module.css b/src/pages/Settings.module.css new file mode 100644 index 0000000..9d9e37e --- /dev/null +++ b/src/pages/Settings.module.css @@ -0,0 +1,267 @@ +/** + * Settings Page Styles + */ + +.settings { + padding: 2rem; + max-width: 800px; + margin: 0 auto; +} + +/* Header */ +.header { + margin-bottom: 2rem; +} + +.header h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary, #1a1a1a); +} + +.subtitle { + margin: 0.5rem 0 0; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +/* Content */ +.content { + display: flex; + flex-direction: column; + gap: 2rem; +} + +/* Section */ +.section { + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 12px; + padding: 1.5rem; +} + +.sectionTitle { + margin: 0 0 1rem; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +/* Setting Row */ +.settingRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; + border-bottom: 1px solid var(--border-color, #e0e0e0); +} + +.settingRow:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.settingRow:first-of-type { + padding-top: 0; +} + +.settingInfo { + flex: 1; +} + +.settingLabel { + display: block; + font-size: 0.9375rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + margin-bottom: 0.25rem; +} + +.settingDescription { + margin: 0; + font-size: 0.8125rem; + color: var(--text-secondary, #666); +} + +.settingControl { + flex-shrink: 0; + margin-left: 1rem; +} + +/* Theme Toggle */ +.themeToggle { + display: flex; + background: var(--surface-color, #f5f5f5); + border-radius: 8px; + padding: 2px; +} + +.themeButton { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + background: transparent; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary, #666); + cursor: pointer; + transition: all 0.2s ease; +} + +.themeButton:hover { + color: var(--text-primary, #1a1a1a); +} + +.themeButton.active { + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #1a1a1a); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Select */ +.select { + padding: 0.5rem 2rem 0.5rem 0.75rem; + border: 1px solid var(--border-color, #d0d0d0); + border-radius: 6px; + background: var(--bg-primary, #ffffff); + font-size: 0.875rem; + color: var(--text-primary, #1a1a1a); + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; +} + +.select:focus { + outline: none; + border-color: var(--primary-color, #2563eb); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +/* Button */ +.button { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color, #d0d0d0); + border-radius: 6px; + background: var(--bg-primary, #ffffff); + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + cursor: pointer; + transition: all 0.2s ease; +} + +.button:hover { + background: var(--surface-color, #f5f5f5); + border-color: var(--border-color, #c0c0c0); +} + +/* Info Card */ +.infoCard { + background: var(--surface-color, #f5f5f5); + border-radius: 8px; + padding: 1rem; +} + +.infoRow { + display: flex; + justify-content: space-between; + padding: 0.5rem 0; +} + +.infoRow:first-child { + padding-top: 0; +} + +.infoRow:last-child { + padding-bottom: 0; +} + +.infoLabel { + font-size: 0.8125rem; + color: var(--text-secondary, #666); +} + +.infoValue { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); +} + +/* Dark Theme */ +:global(.dark-theme) .header h1 { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .subtitle { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .section { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .sectionTitle { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .settingRow { + border-bottom-color: var(--border-dark, #333); +} + +:global(.dark-theme) .settingLabel { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .settingDescription { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .themeToggle { + background: var(--surface-dark, #2a2a2a); +} + +:global(.dark-theme) .themeButton { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .themeButton:hover { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .themeButton.active { + background: var(--bg-dark, #0a0a0a); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .select { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #444); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .button { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #444); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .button:hover { + background: var(--surface-dark, #2a2a2a); +} + +:global(.dark-theme) .infoCard { + background: var(--surface-dark, #2a2a2a); +} + +:global(.dark-theme) .infoLabel { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .infoValue { + color: var(--text-primary-dark, #ffffff); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..ad1923d --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,146 @@ +/** + * Settings Page + * + * Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext). + */ + +import React, { useState } from 'react'; +import { useLanguage } from '../providers/language/LanguageContext'; +import styles from './Settings.module.css'; + +// ============================================================================= +// SETTINGS PAGE +// ============================================================================= + +export const SettingsPage: React.FC = () => { + const { t, language, setLanguage } = useLanguage(); + const [theme, setTheme] = useState<'light' | 'dark'>( + () => (localStorage.getItem('theme') as 'light' | 'dark') || 'light' + ); + + const handleThemeChange = (newTheme: 'light' | 'dark') => { + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + + if (newTheme === 'dark') { + document.documentElement.classList.add('dark-theme'); + document.documentElement.classList.remove('light-theme'); + } else { + document.documentElement.classList.add('light-theme'); + document.documentElement.classList.remove('dark-theme'); + } + document.documentElement.setAttribute('data-theme', newTheme); + }; + + return ( +
+
+

Einstellungen

+

Persönliche Einstellungen und Präferenzen

+
+ +
+ {/* Darstellung */} +
+

Darstellung

+ +
+
+ +

+ Wähle zwischen hellem und dunklem Design. +

+
+
+
+ + +
+
+
+ +
+
+ +

+ Wähle die Anzeigesprache der Anwendung. +

+
+
+ +
+
+
+ + {/* Konto */} +
+

Konto

+ +
+
+ +

+ Ändere deinen Namen, E-Mail-Adresse und Profilbild. +

+
+
+ +
+
+ +
+
+ +

+ Aktualisiere dein Passwort für mehr Sicherheit. +

+
+
+ +
+
+
+ + {/* Info */} +
+

Über

+ +
+
+ Version + 2.0.0 +
+
+ Build + 2026.01.16 +
+
+
+
+
+ ); +}; + +export default SettingsPage; diff --git a/src/pages/index.ts b/src/pages/index.ts new file mode 100644 index 0000000..7738795 --- /dev/null +++ b/src/pages/index.ts @@ -0,0 +1,7 @@ +/** + * Pages Export + */ + +export { DashboardPage } from './Dashboard'; +export { SettingsPage } from './Settings'; +export { FeatureViewPage } from './FeatureView'; diff --git a/src/stores/featureStore.tsx b/src/stores/featureStore.tsx new file mode 100644 index 0000000..a080585 --- /dev/null +++ b/src/stores/featureStore.tsx @@ -0,0 +1,280 @@ +/** + * Feature Store + * + * Verwaltet alle Mandate → Features → Instanzen → Permissions + * Ein User gehört keinem Mandanten direkt an, sondern hat Zugriff auf Feature-Instanzen. + */ + +import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react'; +import type { + Mandate, + MandateFeature, + FeatureInstance, + FeaturesMyResponse, +} from '../types/mandate'; + +// ============================================================================= +// STORE STATE +// ============================================================================= + +interface FeatureState { + mandates: Mandate[]; + loading: boolean; + error: string | null; + initialized: boolean; +} + +interface FeatureActions { + // Laden + loadFeatures: () => Promise; + setFeatures: (response: FeaturesMyResponse) => void; + + // Getters + getMandateById: (mandateId: string) => Mandate | undefined; + getFeatureByCode: (mandateId: string, featureCode: string) => MandateFeature | undefined; + getInstanceById: (instanceId: string) => FeatureInstance | undefined; + getInstancesByFeature: (mandateId: string, featureCode: string) => FeatureInstance[]; + + // Alle Instanzen flach + getAllInstances: () => FeatureInstance[]; + + // Prüfungen + hasAnyInstance: () => boolean; + + // Reset + reset: () => void; +} + +type FeatureStore = FeatureState & FeatureActions; + +// ============================================================================= +// INITIAL STATE +// ============================================================================= + +const initialState: FeatureState = { + mandates: [], + loading: false, + error: null, + initialized: false, +}; + +// ============================================================================= +// CONTEXT +// ============================================================================= + +const FeatureContext = createContext(undefined); + +// ============================================================================= +// PROVIDER +// ============================================================================= + +interface FeatureProviderProps { + children: ReactNode; +} + +export const FeatureProvider: React.FC = ({ children }) => { + const [state, setState] = useState(initialState); + + // Cache für schnellen Zugriff auf Instanzen + const instanceCacheRef = useRef>(new Map()); + + /** + * Lädt alle Features vom Backend + */ + const loadFeatures = useCallback(async () => { + setState(prev => ({ ...prev, loading: true, error: null })); + + try { + // Import dynamisch um zirkuläre Abhängigkeiten zu vermeiden + const { fetchMyFeatures } = await import('../api/featuresApi'); + const response = await fetchMyFeatures(); + + // Cache aufbauen + const cache = new Map(); + response.mandates.forEach(mandate => { + mandate.features.forEach(feature => { + feature.instances.forEach(instance => { + cache.set(instance.id, instance); + }); + }); + }); + instanceCacheRef.current = cache; + + setState({ + mandates: response.mandates, + loading: false, + error: null, + initialized: true, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load features'; + console.error('FeatureStore: Error loading features:', err); + setState(prev => ({ + ...prev, + loading: false, + error: errorMessage, + initialized: true, + })); + } + }, []); + + /** + * Setzt Features direkt (z.B. nach Login) + */ + const setFeatures = useCallback((response: FeaturesMyResponse) => { + // Cache aufbauen + const cache = new Map(); + response.mandates.forEach(mandate => { + mandate.features.forEach(feature => { + feature.instances.forEach(instance => { + cache.set(instance.id, instance); + }); + }); + }); + instanceCacheRef.current = cache; + + setState({ + mandates: response.mandates, + loading: false, + error: null, + initialized: true, + }); + }, []); + + /** + * Holt einen Mandanten per ID + */ + const getMandateById = useCallback((mandateId: string): Mandate | undefined => { + return state.mandates.find(m => m.id === mandateId); + }, [state.mandates]); + + /** + * Holt ein Feature per Mandate-ID und Feature-Code + */ + const getFeatureByCode = useCallback((mandateId: string, featureCode: string): MandateFeature | undefined => { + const mandate = state.mandates.find(m => m.id === mandateId); + return mandate?.features.find(f => f.code === featureCode); + }, [state.mandates]); + + /** + * Holt eine Instanz per ID (schneller Cache-Zugriff) + */ + const getInstanceById = useCallback((instanceId: string): FeatureInstance | undefined => { + return instanceCacheRef.current.get(instanceId); + }, []); + + /** + * Holt alle Instanzen für ein Feature in einem Mandanten + */ + const getInstancesByFeature = useCallback((mandateId: string, featureCode: string): FeatureInstance[] => { + const feature = getFeatureByCode(mandateId, featureCode); + return feature?.instances || []; + }, [getFeatureByCode]); + + /** + * Holt alle Instanzen flach + */ + const getAllInstances = useCallback((): FeatureInstance[] => { + return Array.from(instanceCacheRef.current.values()); + }, []); + + /** + * Prüft ob der User mindestens eine Instanz hat + */ + const hasAnyInstance = useCallback((): boolean => { + return instanceCacheRef.current.size > 0; + }, []); + + /** + * Reset (z.B. bei Logout) + */ + const reset = useCallback(() => { + instanceCacheRef.current.clear(); + setState(initialState); + }, []); + + // Store zusammenbauen + const store: FeatureStore = { + ...state, + loadFeatures, + setFeatures, + getMandateById, + getFeatureByCode, + getInstanceById, + getInstancesByFeature, + getAllInstances, + hasAnyInstance, + reset, + }; + + return ( + + {children} + + ); +}; + +// ============================================================================= +// HOOKS +// ============================================================================= + +/** + * Hook für Zugriff auf den Feature Store + */ +export function useFeatureStore(): FeatureStore { + const context = useContext(FeatureContext); + if (!context) { + throw new Error('useFeatureStore must be used within a FeatureProvider'); + } + return context; +} + +/** + * Hook für alle Mandate + */ +export function useMandates(): Mandate[] { + const store = useFeatureStore(); + return store.mandates; +} + +/** + * Hook für einen spezifischen Mandanten + */ +export function useMandateById(mandateId: string | undefined): Mandate | undefined { + const store = useFeatureStore(); + if (!mandateId) return undefined; + return store.getMandateById(mandateId); +} + +/** + * Hook für eine spezifische Instanz + */ +export function useInstance(instanceId: string | undefined): FeatureInstance | undefined { + const store = useFeatureStore(); + if (!instanceId) return undefined; + return store.getInstanceById(instanceId); +} + +/** + * Hook für Loading-State + */ +export function useFeaturesLoading(): boolean { + const store = useFeatureStore(); + return store.loading; +} + +/** + * Hook für Error-State + */ +export function useFeaturesError(): string | null { + const store = useFeatureStore(); + return store.error; +} + +/** + * Hook für Initialized-State + */ +export function useFeaturesInitialized(): boolean { + const store = useFeatureStore(); + return store.initialized; +} diff --git a/src/types/mandate.ts b/src/types/mandate.ts new file mode 100644 index 0000000..4c00a0d --- /dev/null +++ b/src/types/mandate.ts @@ -0,0 +1,257 @@ +/** + * Multi-Tenant Mandate Types + * + * Hierarchie: Mandate → Feature → Instanz → Views/Permissions + * + * Ein User gehört KEINEM Mandanten direkt an. + * Er hat Zugriff auf Feature-Instanzen, die zu Mandanten gehören. + */ + +// ============================================================================= +// I18N +// ============================================================================= + +export interface I18nLabel { + de: string; + en: string; + fr?: string; +} + +// ============================================================================= +// ACCESS LEVELS +// ============================================================================= + +/** + * Access Level für CRUD-Operationen + * - 'n': None - Kein Zugriff + * - 'm': My - Nur eigene Datensätze + * - 'g': Group - Alle Datensätze der Instanz + * - 'a': All - Alle Datensätze (mandantenübergreifend) + */ +export type AccessLevel = 'n' | 'm' | 'g' | 'a'; + +// ============================================================================= +// PERMISSIONS +// ============================================================================= + +/** + * Tabellen-Berechtigungen + */ +export interface TablePermission { + view: boolean; + read: AccessLevel; + create: AccessLevel; + update: AccessLevel; + delete: AccessLevel; +} + +/** + * Feld-Berechtigungen (optional, nur wo eingeschränkt) + */ +export interface FieldPermission { + read: boolean; + write: boolean; +} + +/** + * Summarische Berechtigungen pro Feature-Instanz + * Werden einmalig beim Login/Refresh geladen + */ +export interface InstancePermissions { + // Tabellen-Level (CRUD pro Tabelle) + tables: Record; + + // Feld-Level (nur wo eingeschränkt) + fields?: Record>; + + // View-Level (Navigation) + views: Record; +} + +// ============================================================================= +// FEATURE INSTANCE +// ============================================================================= + +/** + * Eine Feature-Instanz ist die Arbeitseinheit für einen User + * z.B. "Trustee für PamoCreate AG bei Soha Treuhand" + */ +export interface FeatureInstance { + id: string; // UUID der Instanz + featureCode: string; // "trustee", "chatbot", "chatworkflow", etc. + mandateId: string; // Zugehöriger Mandant + mandateName: string; // Für Anzeige + instanceLabel: string; // z.B. "PamoCreate AG" + userRole: string; // Rolle des Users in dieser Instanz + permissions: InstancePermissions; +} + +// ============================================================================= +// MANDATE FEATURE +// ============================================================================= + +/** + * Ein Feature innerhalb eines Mandanten + * Gruppiert alle Instanzen eines Feature-Typs + */ +export interface MandateFeature { + code: string; // "trustee", "chatbot", "chatworkflow", etc. + label: I18nLabel; // { de: "Treuhand", en: "Trustee" } + icon: string; // Material/React Icon Name + instances: FeatureInstance[]; +} + +// ============================================================================= +// MANDATE +// ============================================================================= + +/** + * Ein Mandant (oberste Ebene) + * Enthält mehrere Features mit deren Instanzen + */ +export interface Mandate { + id: string; // mandateId + name: string; // Anzeige-Name + code?: string; // Optionaler Code + features: MandateFeature[]; +} + +// ============================================================================= +// API RESPONSE +// ============================================================================= + +/** + * Response von GET /features/my + * Enthält alle für den User sichtbaren Mandate + Features + Instanzen + Permissions + */ +export interface FeaturesMyResponse { + mandates: Mandate[]; +} + +// ============================================================================= +// USER (Ohne Mandant-Zugehörigkeit) +// ============================================================================= + +/** + * User-Daten nach Login + * KEIN mandateId mehr - User arbeitet mit Feature-Instanzen + */ +export interface User { + id: string; + username: string; + email: string; + fullName: string; + language: string; + enabled: boolean; + authenticationAuthority: string; + isSysAdmin: boolean; + roleLabels?: string[]; // System-weite Rollen (z.B. ["sysadmin"]) +} + +// ============================================================================= +// NAVIGATION +// ============================================================================= + +/** + * View-Definition für Feature-Navigation + */ +export interface FeatureView { + code: string; // z.B. "dashboard", "contracts", "documents" + label: I18nLabel; + icon?: string; + path: string; // Relativer Pfad innerhalb der Instanz +} + +/** + * Feature-Konfiguration für Navigation + * Definiert welche Views ein Feature hat + */ +export interface FeatureConfig { + code: string; + label: I18nLabel; + icon: string; + views: FeatureView[]; +} + +// ============================================================================= +// FEATURE REGISTRY +// ============================================================================= + +/** + * Registry aller verfügbaren Features mit ihren Views + * Wird verwendet um Navigation zu generieren + */ +export const FEATURE_REGISTRY: Record = { + trustee: { + code: 'trustee', + label: { de: 'Treuhand', en: 'Trustee' }, + icon: 'briefcase', + views: [ + { code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' }, + { code: 'organisations', label: { de: 'Organisationen', en: 'Organisations' }, path: 'organisations' }, + { code: 'contracts', label: { de: 'Verträge', en: 'Contracts' }, path: 'contracts' }, + { code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' }, + { code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' }, + { code: 'roles', label: { de: 'Rollen', en: 'Roles' }, path: 'roles' }, + { code: 'access', label: { de: 'Zugriffe', en: 'Access' }, path: 'access' }, + ] + }, + chatworkflow: { + code: 'chatworkflow', + label: { de: 'Workflow', en: 'Workflow' }, + icon: 'play_circle', + views: [ + { code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' }, + { code: 'runs', label: { de: 'Runs', en: 'Runs' }, path: 'runs' }, + { code: 'files', label: { de: 'Dateien', en: 'Files' }, path: 'files' }, + ] + }, + chatbot: { + code: 'chatbot', + label: { de: 'Chatbot', en: 'Chatbot' }, + icon: 'chat', + views: [ + { code: 'conversations', label: { de: 'Konversationen', en: 'Conversations' }, path: 'conversations' }, + { code: 'settings', label: { de: 'Einstellungen', en: 'Settings' }, path: 'settings' }, + ] + }, +}; + +// ============================================================================= +// HELPERS +// ============================================================================= + +/** + * Prüft ob ein AccessLevel Zugriff gewährt (nicht 'n') + */ +export function hasAccess(level: AccessLevel): boolean { + return level !== 'n'; +} + +/** + * Prüft ob ein User einen Datensatz bearbeiten darf basierend auf AccessLevel + */ +export function canAccessRecord( + level: AccessLevel, + record: { _createdBy?: string }, + userId: string +): boolean { + switch (level) { + case 'n': + return false; + case 'm': + return record._createdBy === userId; + case 'g': + case 'a': + return true; + default: + return false; + } +} + +/** + * Holt das Label für die aktuelle Sprache + */ +export function getLabel(label: I18nLabel, lang: 'de' | 'en' | 'fr' = 'de'): string { + return label[lang] || label.de || label.en || ''; +} diff --git a/src/utils/userCache.ts b/src/utils/userCache.ts index 9b461c8..4ec71c5 100644 --- a/src/utils/userCache.ts +++ b/src/utils/userCache.ts @@ -19,7 +19,9 @@ export interface CachedUserData { fullName: string; privilege?: string; // Deprecated - use roleLabels instead roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"]) - mandateId: string; + // mandateId entfernt - User gehört keinem Mandanten direkt an + // Stattdessen hat er Zugriff auf Feature-Instanzen (siehe featureStore) + isSysAdmin?: boolean; // System-Administrator Flag language: string; enabled: boolean; authenticationAuthority: string;