prepared multimandate

This commit is contained in:
ValueOn AG 2026-01-17 02:18:24 +01:00
parent 54ba020c45
commit 8033ca9207
22 changed files with 3666 additions and 19 deletions

View file

@ -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 */}
{/* ================================================== */}
<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 />} />
{/* Catch-all redirect to home */}
{/* 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 Dashboard */}
{/* ================================================== */}
<Route path="*" element={
<ProtectedRoute>
<FileProvider>
<WorkflowSelectionProvider>
<Home />
</WorkflowSelectionProvider>
</FileProvider>
<MainLayout />
</ProtectedRoute>
} />
</Routes>

233
src/api/featuresApi.ts Normal file
View 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'
);
}

View 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);
}

View 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;

View file

@ -0,0 +1,5 @@
/**
* Navigation Components Export
*/
export { MandateNavigation } from './MandateNavigation';

View 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;
}

View 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}</>;
}

View 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);
}

View 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;

View 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);
}

View 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
View file

@ -0,0 +1,6 @@
/**
* Layouts Export
*/
export { MainLayout } from './MainLayout';
export { FeatureLayout, ProtectedFeatureRoute } from './FeatureLayout';

View 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
View 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;

View 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
View 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;

View 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
View 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
View 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
View 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
View 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 || '';
}

View file

@ -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;