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 { useEffect } from 'react';
|
||||
|
||||
// Import global CSS reset first
|
||||
import './index.css';
|
||||
|
||||
// Auth Pages (Public)
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import PasswordResetRequest from './pages/PasswordResetRequest';
|
||||
import Reset from './pages/Reset';
|
||||
|
||||
// Providers
|
||||
import { AuthProvider } from './providers/auth/AuthProvider';
|
||||
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||
import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext';
|
||||
import { FileProvider } from './contexts/FileContext';
|
||||
import Home from './pages/Home/Home';
|
||||
|
||||
// Layouts
|
||||
import { MainLayout } from './layouts/MainLayout';
|
||||
import { FeatureLayout } from './layouts/FeatureLayout';
|
||||
|
||||
// Pages
|
||||
import { DashboardPage } from './pages/Dashboard';
|
||||
import { SettingsPage } from './pages/Settings';
|
||||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
|
|
@ -38,36 +58,75 @@ function App() {
|
|||
}
|
||||
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
|
||||
{/* ================================================== */}
|
||||
{/* PUBLIC AUTH ROUTES - NO AUTHENTICATION REQUIRED */}
|
||||
{/* ================================================== */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/password-reset-request" element={<PasswordResetRequest />} />
|
||||
<Route path="/reset" element={<Reset />} />
|
||||
|
||||
{/* PROTECTED ROUTE - requires authentication */}
|
||||
{/* ================================================== */}
|
||||
{/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */}
|
||||
{/* ================================================== */}
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<FileProvider>
|
||||
<WorkflowSelectionProvider>
|
||||
<Home />
|
||||
</WorkflowSelectionProvider>
|
||||
</FileProvider>
|
||||
<MainLayout />
|
||||
</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={
|
||||
<ProtectedRoute>
|
||||
<FileProvider>
|
||||
<WorkflowSelectionProvider>
|
||||
<Home />
|
||||
</WorkflowSelectionProvider>
|
||||
</FileProvider>
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</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;
|
||||
privilege?: string; // Deprecated - use roleLabels instead
|
||||
roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"])
|
||||
mandateId: string;
|
||||
// mandateId entfernt - User gehört keinem Mandanten direkt an
|
||||
// Stattdessen hat er Zugriff auf Feature-Instanzen (siehe featureStore)
|
||||
isSysAdmin?: boolean; // System-Administrator Flag
|
||||
language: string;
|
||||
enabled: boolean;
|
||||
authenticationAuthority: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue