prepared multimandate
This commit is contained in:
parent
54ba020c45
commit
8033ca9207
22 changed files with 3666 additions and 19 deletions
95
src/App.tsx
95
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 { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
// Import global CSS reset first
|
// Import global CSS reset first
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
// Auth Pages (Public)
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import PasswordResetRequest from './pages/PasswordResetRequest';
|
import PasswordResetRequest from './pages/PasswordResetRequest';
|
||||||
import Reset from './pages/Reset';
|
import Reset from './pages/Reset';
|
||||||
|
|
||||||
|
// Providers
|
||||||
import { AuthProvider } from './providers/auth/AuthProvider';
|
import { AuthProvider } from './providers/auth/AuthProvider';
|
||||||
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
|
||||||
import { FileProvider } from './contexts/FileContext';
|
// Layouts
|
||||||
import Home from './pages/Home/Home';
|
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() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// 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');
|
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
|
{/* ================================================== */}
|
||||||
|
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
|
||||||
|
{/* ================================================== */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/password-reset-request" element={<PasswordResetRequest />} />
|
<Route path="/password-reset-request" element={<PasswordResetRequest />} />
|
||||||
<Route path="/reset" element={<Reset />} />
|
<Route path="/reset" element={<Reset />} />
|
||||||
|
|
||||||
{/* PROTECTED ROUTE - requires authentication */}
|
{/* ================================================== */}
|
||||||
|
{/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */}
|
||||||
|
{/* ================================================== */}
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FileProvider>
|
<MainLayout />
|
||||||
<WorkflowSelectionProvider>
|
|
||||||
<Home />
|
|
||||||
</WorkflowSelectionProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
}>
|
||||||
|
{/* Dashboard (Root) */}
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
|
||||||
|
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
|
{/* ============================================== */}
|
||||||
|
{/* FEATURE-INSTANZ ROUTES */}
|
||||||
|
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
||||||
|
{/* ============================================== */}
|
||||||
|
<Route
|
||||||
|
path="mandates/:mandateId/:featureCode/:instanceId"
|
||||||
|
element={<FeatureLayout />}
|
||||||
|
>
|
||||||
|
{/* Feature Views - dynamisch basierend auf featureCode */}
|
||||||
|
<Route index element={<FeatureViewPage view="dashboard" />} />
|
||||||
|
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
|
||||||
|
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
|
||||||
|
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
|
||||||
|
<Route path="documents" element={<FeatureViewPage view="documents" />} />
|
||||||
|
<Route path="positions" element={<FeatureViewPage view="positions" />} />
|
||||||
|
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
||||||
|
<Route path="access" element={<FeatureViewPage view="access" />} />
|
||||||
|
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
||||||
|
<Route path="files" element={<FeatureViewPage view="files" />} />
|
||||||
|
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
|
||||||
|
|
||||||
|
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||||
|
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* ============================================== */}
|
||||||
|
{/* ADMIN ROUTES (nur SysAdmin) */}
|
||||||
|
{/* ============================================== */}
|
||||||
|
<Route path="admin">
|
||||||
|
<Route path="mandates" element={<div>Admin: Mandanten (TODO)</div>} />
|
||||||
|
<Route path="users" element={<div>Admin: Benutzer (TODO)</div>} />
|
||||||
|
<Route path="roles" element={<div>Admin: Globale Rollen (TODO)</div>} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Catch-all redirect to home */}
|
{/* ================================================== */}
|
||||||
|
{/* CATCH-ALL - Redirect to Dashboard */}
|
||||||
|
{/* ================================================== */}
|
||||||
<Route path="*" element={
|
<Route path="*" element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<FileProvider>
|
<MainLayout />
|
||||||
<WorkflowSelectionProvider>
|
|
||||||
<Home />
|
|
||||||
</WorkflowSelectionProvider>
|
|
||||||
</FileProvider>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
@ -77,4 +136,4 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
233
src/api/featuresApi.ts
Normal file
233
src/api/featuresApi.ts
Normal file
|
|
@ -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<FeaturesMyResponse> {
|
||||||
|
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<FeaturesMyResponse>('/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<MandateFeature[]> {
|
||||||
|
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<MandateFeature[]>('/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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof instance.id === 'string' &&
|
||||||
|
typeof instance.featureCode === 'string' &&
|
||||||
|
typeof instance.mandateId === 'string' &&
|
||||||
|
typeof instance.instanceLabel === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
352
src/components/Navigation/MandateNavigation.module.css
Normal file
352
src/components/Navigation/MandateNavigation.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
347
src/components/Navigation/MandateNavigation.tsx
Normal file
347
src/components/Navigation/MandateNavigation.tsx
Normal file
|
|
@ -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<string, React.ReactNode> = {
|
||||||
|
trustee: <FaBriefcase />,
|
||||||
|
chatbot: <FaRobot />,
|
||||||
|
chatworkflow: <FaPlay />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SYSTEM SECTION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const SystemSection: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.sectionTitle}>SYSTEM</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.sectionContent}>
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.navItem} ${isActive && location.pathname === '/' ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaHome className={styles.navIcon} />
|
||||||
|
<span>Übersicht</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/settings"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaCog className={styles.navIcon} />
|
||||||
|
<span>Einstellungen</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// INSTANCE NAV GROUP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface InstanceNavGroupProps {
|
||||||
|
instance: FeatureInstance;
|
||||||
|
mandateId: string;
|
||||||
|
featureCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceNavGroup: React.FC<InstanceNavGroupProps> = ({
|
||||||
|
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 (
|
||||||
|
<div className={`${styles.instanceGroup} ${isInInstance ? styles.activeInstance : ''}`}>
|
||||||
|
<button
|
||||||
|
className={styles.instanceHeader}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{isExpanded ? <FaChevronDown className={styles.chevron} /> : <FaChevronRight className={styles.chevron} />}
|
||||||
|
<span className={styles.instanceLabel}>{instance.instanceLabel}</span>
|
||||||
|
<span className={styles.roleBadge}>{instance.userRole}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className={styles.instanceViews}>
|
||||||
|
{visibleViews.map(view => (
|
||||||
|
<NavLink
|
||||||
|
key={view.code}
|
||||||
|
to={`${basePath}/${view.path}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.viewItem} ${isActive ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{getLabel(view.label)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// FEATURE NAV GROUP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface FeatureNavGroupProps {
|
||||||
|
feature: MandateFeature;
|
||||||
|
mandateId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureNavGroup: React.FC<FeatureNavGroupProps> = ({ 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 (
|
||||||
|
<div className={`${styles.featureGroup} ${isInFeature ? styles.activeFeature : ''}`}>
|
||||||
|
<button
|
||||||
|
className={styles.featureHeader}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{isExpanded ? <FaChevronDown className={styles.chevron} /> : <FaChevronRight className={styles.chevron} />}
|
||||||
|
<span className={styles.featureIcon}>
|
||||||
|
{FEATURE_ICONS[feature.code] || <FaBriefcase />}
|
||||||
|
</span>
|
||||||
|
<span className={styles.featureLabel}>{getLabel(feature.label)}</span>
|
||||||
|
<span className={styles.instanceCount}>{feature.instances.length}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className={styles.featureContent}>
|
||||||
|
{feature.instances.map(instance => (
|
||||||
|
<InstanceNavGroup
|
||||||
|
key={instance.id}
|
||||||
|
instance={instance}
|
||||||
|
mandateId={mandateId}
|
||||||
|
featureCode={feature.code}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MANDATE NAV GROUP
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface MandateNavGroupProps {
|
||||||
|
mandate: Mandate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MandateNavGroup: React.FC<MandateNavGroupProps> = ({ 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 (
|
||||||
|
<div className={`${styles.mandateGroup} ${isInMandate ? styles.activeMandate : ''}`}>
|
||||||
|
<button
|
||||||
|
className={styles.mandateHeader}
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
{isExpanded ? <FaChevronDown className={styles.chevron} /> : <FaChevronRight className={styles.chevron} />}
|
||||||
|
<span className={styles.mandateLabel}>{mandate.name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className={styles.mandateContent}>
|
||||||
|
{mandate.features.map(feature => (
|
||||||
|
<FeatureNavGroup
|
||||||
|
key={feature.code}
|
||||||
|
feature={feature}
|
||||||
|
mandateId={mandate.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ADMIN SECTION
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface AdminSectionProps {
|
||||||
|
isSysAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminSection: React.FC<AdminSectionProps> = ({ isSysAdmin }) => {
|
||||||
|
if (!isSysAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<span className={styles.sectionTitle}>ADMINISTRATION</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.sectionContent}>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/mandates"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RiAdminFill className={styles.navIcon} />
|
||||||
|
<span>Mandanten</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/users"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RiAdminFill className={styles.navIcon} />
|
||||||
|
<span>Benutzer</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/roles"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RiAdminFill className={styles.navIcon} />
|
||||||
|
<span>Globale Rollen</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EMPTY STATE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const EmptyState: React.FC = () => (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<p>Keine Feature-Instanzen verfügbar.</p>
|
||||||
|
<p className={styles.emptyHint}>
|
||||||
|
Kontaktiere einen Administrator, um Zugriff zu erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const MandateNavigation: React.FC = () => {
|
||||||
|
const mandates = useMandates();
|
||||||
|
const { hasAnyInstance } = useFeatureStore();
|
||||||
|
|
||||||
|
// TODO: Aus Auth-Store holen
|
||||||
|
const isSysAdmin = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.navigation}>
|
||||||
|
{/* System-Bereich (immer sichtbar) */}
|
||||||
|
<SystemSection />
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className={styles.separator} />
|
||||||
|
|
||||||
|
{/* Mandanten & Features */}
|
||||||
|
{hasAnyInstance() ? (
|
||||||
|
mandates.map(mandate => (
|
||||||
|
<MandateNavGroup key={mandate.id} mandate={mandate} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Separator vor Admin */}
|
||||||
|
{isSysAdmin && <div className={styles.separator} />}
|
||||||
|
|
||||||
|
{/* Admin-Bereich (nur für SysAdmin) */}
|
||||||
|
<AdminSection isSysAdmin={isSysAdmin} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MandateNavigation;
|
||||||
5
src/components/Navigation/index.ts
Normal file
5
src/components/Navigation/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* Navigation Components Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { MandateNavigation } from './MandateNavigation';
|
||||||
137
src/hooks/useCurrentInstance.ts
Normal file
137
src/hooks/useCurrentInstance.ts
Normal file
|
|
@ -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 <Navigate to="/" />;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Arbeite mit instance.permissions, etc.
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useCurrentInstance(): CurrentInstanceContext {
|
||||||
|
const params = useParams<FeatureRouteParams>();
|
||||||
|
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<FeatureRouteParams>();
|
||||||
|
return params.instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook für den Feature-Code aus der URL
|
||||||
|
*/
|
||||||
|
export function useFeatureCode(): string | undefined {
|
||||||
|
const params = useParams<FeatureRouteParams>();
|
||||||
|
return params.featureCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook für die Mandate-ID aus der URL
|
||||||
|
*/
|
||||||
|
export function useMandateId(): string | undefined {
|
||||||
|
const params = useParams<FeatureRouteParams>();
|
||||||
|
return params.mandateId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook der prüft ob wir in einem Feature-Kontext sind
|
||||||
|
*/
|
||||||
|
export function useIsInFeatureContext(): boolean {
|
||||||
|
const { isValid } = useCurrentInstance();
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
299
src/hooks/useInstancePermissions.ts
Normal file
299
src/hooks/useInstancePermissions.ts
Normal file
|
|
@ -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 (
|
||||||
|
* <div>
|
||||||
|
* {canCreate && <Button>Neu</Button>}
|
||||||
|
* {contracts.map(c => (
|
||||||
|
* <Row key={c.id}>
|
||||||
|
* {canUpdate(c) && <EditButton />}
|
||||||
|
* {canDelete(c) && <DeleteButton />}
|
||||||
|
* </Row>
|
||||||
|
* ))}
|
||||||
|
* </div>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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 (
|
||||||
|
* <nav>
|
||||||
|
* {canViewContracts && <NavLink to="contracts">Verträge</NavLink>}
|
||||||
|
* </nav>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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<string, boolean> {
|
||||||
|
const { instance } = useCurrentInstance();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const result: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
* <form>
|
||||||
|
* {canRead && (
|
||||||
|
* <TextField
|
||||||
|
* name="salary"
|
||||||
|
* disabled={!canWrite}
|
||||||
|
* />
|
||||||
|
* )}
|
||||||
|
* </form>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* <PermissionGate table="TrusteeContract" action="create">
|
||||||
|
* <Button>Neuer Vertrag</Button>
|
||||||
|
* </PermissionGate>
|
||||||
|
*
|
||||||
|
* <PermissionGate view="trustee-admin" fallback={<AccessDenied />}>
|
||||||
|
* <AdminPanel />
|
||||||
|
* </PermissionGate>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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}</>;
|
||||||
|
}
|
||||||
174
src/layouts/FeatureLayout.module.css
Normal file
174
src/layouts/FeatureLayout.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
151
src/layouts/FeatureLayout.tsx
Normal file
151
src/layouts/FeatureLayout.tsx
Normal file
|
|
@ -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 = () => (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.loadingSpinner} />
|
||||||
|
<p>Lade Feature-Daten...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ERROR COMPONENT
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ErrorScreenProps {
|
||||||
|
message: string;
|
||||||
|
returnPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorScreen: React.FC<ErrorScreenProps> = ({ message, returnPath = '/' }) => (
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<div className={styles.errorIcon}>⚠️</div>
|
||||||
|
<h2>Zugriff nicht möglich</h2>
|
||||||
|
<p>{message}</p>
|
||||||
|
<a href={returnPath} className={styles.errorLink}>
|
||||||
|
Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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 <Outlet /> 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 <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<ErrorScreen
|
||||||
|
message="Die angeforderte Feature-Instanz existiert nicht oder Sie haben keinen Zugriff."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alles OK - rendere Content
|
||||||
|
return (
|
||||||
|
<div className={styles.featureLayout}>
|
||||||
|
{/* Header mit Instanz-Info */}
|
||||||
|
<header className={styles.featureHeader}>
|
||||||
|
<div className={styles.breadcrumb}>
|
||||||
|
<span className={styles.mandateName}>{mandate?.name}</span>
|
||||||
|
<span className={styles.separator}>/</span>
|
||||||
|
<span className={styles.featureName}>{feature?.label?.de || feature?.code}</span>
|
||||||
|
<span className={styles.separator}>/</span>
|
||||||
|
<span className={styles.instanceName}>{instance?.instanceLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.roleIndicator}>
|
||||||
|
<span className={styles.roleBadge}>{instance?.userRole}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<main className={styles.featureContent}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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<ProtectedFeatureRouteProps> = ({
|
||||||
|
requiredView,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { instance, isValid } = useCurrentInstance();
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe View-Berechtigung wenn erforderlich
|
||||||
|
if (requiredView) {
|
||||||
|
const hasViewAccess = instance?.permissions?.views?.[requiredView] ?? false;
|
||||||
|
|
||||||
|
if (!hasViewAccess) {
|
||||||
|
return (
|
||||||
|
<ErrorScreen
|
||||||
|
message={`Sie haben keine Berechtigung für diesen Bereich (${requiredView}).`}
|
||||||
|
returnPath={`/mandates/${instance?.mandateId}/${instance?.featureCode}/${instance?.id}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureLayout;
|
||||||
132
src/layouts/MainLayout.module.css
Normal file
132
src/layouts/MainLayout.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
83
src/layouts/MainLayout.tsx
Normal file
83
src/layouts/MainLayout.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={styles.mainLayout}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<div className={styles.logoContainer}>
|
||||||
|
<div className={styles.logoText}>
|
||||||
|
<span className={styles.logoPower}>Power</span>
|
||||||
|
<span className={styles.logoOn}>On</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className={styles.navigation}>
|
||||||
|
{loading && (
|
||||||
|
<div className={styles.loadingNav}>
|
||||||
|
Lade Navigation...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorNav}>
|
||||||
|
Fehler: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{initialized && !loading && (
|
||||||
|
<MandateNavigation />
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User-Bereich am unteren Rand */}
|
||||||
|
<div className={styles.userSection}>
|
||||||
|
{/* TODO: User-Info Komponente */}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className={styles.content}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN LAYOUT (mit Provider)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const MainLayout: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<FeatureProvider>
|
||||||
|
<MainLayoutInner />
|
||||||
|
</FeatureProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
6
src/layouts/index.ts
Normal file
6
src/layouts/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/**
|
||||||
|
* Layouts Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { MainLayout } from './MainLayout';
|
||||||
|
export { FeatureLayout, ProtectedFeatureRoute } from './FeatureLayout';
|
||||||
247
src/pages/Dashboard.module.css
Normal file
247
src/pages/Dashboard.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
139
src/pages/Dashboard.tsx
Normal file
139
src/pages/Dashboard.tsx
Normal file
|
|
@ -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<string, React.ReactNode> = {
|
||||||
|
trustee: <FaBriefcase size={24} />,
|
||||||
|
chatbot: <FaRobot size={24} />,
|
||||||
|
chatworkflow: <FaPlay size={24} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// INSTANCE CARD
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface InstanceCardProps {
|
||||||
|
instance: FeatureInstance;
|
||||||
|
featureLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InstanceCard: React.FC<InstanceCardProps> = ({ 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 (
|
||||||
|
<Link to={targetPath} className={styles.instanceCard}>
|
||||||
|
<div className={styles.cardIcon}>
|
||||||
|
{FEATURE_ICONS[instance.featureCode] || <FaBriefcase size={24} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardContent}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<span className={styles.featureLabel}>{featureLabel}</span>
|
||||||
|
<span className={styles.roleBadge}>{instance.userRole}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className={styles.instanceLabel}>{instance.instanceLabel}</h3>
|
||||||
|
<p className={styles.mandateName}>{instance.mandateName}</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardArrow}>
|
||||||
|
<FaArrowRight />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EMPTY STATE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const EmptyState: React.FC = () => (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyIcon}>📋</div>
|
||||||
|
<h2>Willkommen bei PowerOn</h2>
|
||||||
|
<p>Du hast aktuell Zugriff auf keine Feature-Instanzen.</p>
|
||||||
|
<p>Kontaktiere einen Administrator, um Zugriff zu erhalten.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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<string, FeatureInstance[]>);
|
||||||
|
|
||||||
|
if (!hasAnyInstance()) {
|
||||||
|
return <EmptyState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboard}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1>Übersicht</h1>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Du hast Zugriff auf {allInstances.length} Feature-Instanz{allInstances.length !== 1 ? 'en' : ''}
|
||||||
|
in {mandates.length} Mandant{mandates.length !== 1 ? 'en' : ''}.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
{Object.entries(instancesByFeature).map(([featureCode, instances]) => {
|
||||||
|
const featureConfig = FEATURE_REGISTRY[featureCode];
|
||||||
|
const featureLabel = featureConfig ? getLabel(featureConfig.label) : featureCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={featureCode} className={styles.featureSection}>
|
||||||
|
<h2 className={styles.sectionTitle}>
|
||||||
|
{FEATURE_ICONS[featureCode]}
|
||||||
|
<span>{featureLabel}</span>
|
||||||
|
</h2>
|
||||||
|
<div className={styles.instanceGrid}>
|
||||||
|
{instances.map(instance => (
|
||||||
|
<InstanceCard
|
||||||
|
key={instance.id}
|
||||||
|
instance={instance}
|
||||||
|
featureLabel={featureLabel}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
122
src/pages/FeatureView.module.css
Normal file
122
src/pages/FeatureView.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
202
src/pages/FeatureView.tsx
Normal file
202
src/pages/FeatureView.tsx
Normal file
|
|
@ -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 = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Trustee Dashboard</h2>
|
||||||
|
<p>Übersicht der Treuhand-Aktivitäten</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrusteeOrganisations: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Organisationen</h2>
|
||||||
|
<p>Verwaltung der Organisationen</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrusteeContracts: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Verträge</h2>
|
||||||
|
<p>Vertragsverwaltung</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrusteeDocuments: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Dokumente</h2>
|
||||||
|
<p>Dokumentenverwaltung</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrusteePositions: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Positionen</h2>
|
||||||
|
<p>Positionsverwaltung</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrusteeRoles: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Rollen</h2>
|
||||||
|
<p>Rollenverwaltung</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrusteeAccess: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Zugriffe</h2>
|
||||||
|
<p>Zugriffsverwaltung</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chatworkflow Views
|
||||||
|
const ChatworkflowDashboard: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Workflow Dashboard</h2>
|
||||||
|
<p>Übersicht der Workflows</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChatworkflowRuns: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Runs</h2>
|
||||||
|
<p>Workflow-Ausführungen</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChatworkflowFiles: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Dateien</h2>
|
||||||
|
<p>Workflow-Dateien</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chatbot Views
|
||||||
|
const ChatbotConversations: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Konversationen</h2>
|
||||||
|
<p>Chat-Konversationen</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChatbotSettings: React.FC = () => (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Chatbot Einstellungen</h2>
|
||||||
|
<p>Konfiguration des Chatbots</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generic/Fallback
|
||||||
|
const NotFound: React.FC = () => (
|
||||||
|
<div className={styles.notFound}>
|
||||||
|
<h2>Seite nicht gefunden</h2>
|
||||||
|
<p>Diese View existiert nicht oder wurde noch nicht implementiert.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AccessDenied: React.FC = () => (
|
||||||
|
<div className={styles.accessDenied}>
|
||||||
|
<h2>Zugriff verweigert</h2>
|
||||||
|
<p>Du hast keine Berechtigung für diese Ansicht.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// VIEW REGISTRY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type ViewComponent = React.FC;
|
||||||
|
|
||||||
|
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
|
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<FeatureViewPageProps> = ({ view }) => {
|
||||||
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
|
|
||||||
|
// Berechtigungs-Check
|
||||||
|
const viewCode = `${featureCode}-${view}`;
|
||||||
|
const canView = useCanViewFeatureView(viewCode);
|
||||||
|
|
||||||
|
// Nicht valider Kontext
|
||||||
|
if (!isValid || !featureCode || !instance) {
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine Berechtigung
|
||||||
|
if (!canView && view !== 'not-found') {
|
||||||
|
return <AccessDenied />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View-Komponente finden
|
||||||
|
const featureViews = VIEW_COMPONENTS[featureCode];
|
||||||
|
if (!featureViews) {
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ViewComponent = featureViews[view];
|
||||||
|
if (!ViewComponent) {
|
||||||
|
return <NotFound />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className={styles.featureView}>
|
||||||
|
<header className={styles.viewHeader}>
|
||||||
|
<h1 className={styles.viewTitle}>{viewLabel}</h1>
|
||||||
|
</header>
|
||||||
|
<main className={styles.viewContent}>
|
||||||
|
<ViewComponent />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureViewPage;
|
||||||
267
src/pages/Settings.module.css
Normal file
267
src/pages/Settings.module.css
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
146
src/pages/Settings.tsx
Normal file
146
src/pages/Settings.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={styles.settings}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1>Einstellungen</h1>
|
||||||
|
<p className={styles.subtitle}>Persönliche Einstellungen und Präferenzen</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={styles.content}>
|
||||||
|
{/* Darstellung */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
||||||
|
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}>
|
||||||
|
<label className={styles.settingLabel}>Theme</label>
|
||||||
|
<p className={styles.settingDescription}>
|
||||||
|
Wähle zwischen hellem und dunklem Design.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<div className={styles.themeToggle}>
|
||||||
|
<button
|
||||||
|
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`}
|
||||||
|
onClick={() => handleThemeChange('light')}
|
||||||
|
>
|
||||||
|
☀️ Hell
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`}
|
||||||
|
onClick={() => handleThemeChange('dark')}
|
||||||
|
>
|
||||||
|
🌙 Dunkel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}>
|
||||||
|
<label className={styles.settingLabel}>Sprache</label>
|
||||||
|
<p className={styles.settingDescription}>
|
||||||
|
Wähle die Anzeigesprache der Anwendung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={language}
|
||||||
|
onChange={(e) => setLanguage(e.target.value as 'de' | 'en' | 'fr')}
|
||||||
|
>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Konto */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Konto</h2>
|
||||||
|
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}>
|
||||||
|
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||||
|
<p className={styles.settingDescription}>
|
||||||
|
Ändere deinen Namen, E-Mail-Adresse und Profilbild.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<button className={styles.button}>
|
||||||
|
Profil öffnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}>
|
||||||
|
<label className={styles.settingLabel}>Passwort ändern</label>
|
||||||
|
<p className={styles.settingDescription}>
|
||||||
|
Aktualisiere dein Passwort für mehr Sicherheit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<button className={styles.button}>
|
||||||
|
Passwort ändern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Über</h2>
|
||||||
|
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<div className={styles.infoRow}>
|
||||||
|
<span className={styles.infoLabel}>Version</span>
|
||||||
|
<span className={styles.infoValue}>2.0.0</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoRow}>
|
||||||
|
<span className={styles.infoLabel}>Build</span>
|
||||||
|
<span className={styles.infoValue}>2026.01.16</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
7
src/pages/index.ts
Normal file
7
src/pages/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Pages Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DashboardPage } from './Dashboard';
|
||||||
|
export { SettingsPage } from './Settings';
|
||||||
|
export { FeatureViewPage } from './FeatureView';
|
||||||
280
src/stores/featureStore.tsx
Normal file
280
src/stores/featureStore.tsx
Normal file
|
|
@ -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<void>;
|
||||||
|
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<FeatureStore | undefined>(undefined);
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PROVIDER
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface FeatureProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeatureProvider: React.FC<FeatureProviderProps> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<FeatureState>(initialState);
|
||||||
|
|
||||||
|
// Cache für schnellen Zugriff auf Instanzen
|
||||||
|
const instanceCacheRef = useRef<Map<string, FeatureInstance>>(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<string, FeatureInstance>();
|
||||||
|
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<string, FeatureInstance>();
|
||||||
|
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 (
|
||||||
|
<FeatureContext.Provider value={store}>
|
||||||
|
{children}
|
||||||
|
</FeatureContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
257
src/types/mandate.ts
Normal file
257
src/types/mandate.ts
Normal file
|
|
@ -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<string, TablePermission>;
|
||||||
|
|
||||||
|
// Feld-Level (nur wo eingeschränkt)
|
||||||
|
fields?: Record<string, Record<string, FieldPermission>>;
|
||||||
|
|
||||||
|
// View-Level (Navigation)
|
||||||
|
views: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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<string, FeatureConfig> = {
|
||||||
|
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 || '';
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,9 @@ export interface CachedUserData {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
privilege?: string; // Deprecated - use roleLabels instead
|
privilege?: string; // Deprecated - use roleLabels instead
|
||||||
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
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;
|
language: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue