From 2b220fe816ea605bfc9f8d014292f174f29abff3 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 25 Jan 2026 23:57:47 +0100 Subject: [PATCH] gpdr compliancy implemented --- src/App.tsx | 7 +- src/api.ts | 16 +- .../FormGeneratorForm/FormGeneratorForm.tsx | 14 +- src/config/pageRegistry.tsx | 3 + .../PageManager/data/pages/automations.ts | 4 +- src/hooks/useInvitations.ts | 29 - src/pages/FeatureView.tsx | 2 + src/pages/GDPR.module.css | 251 +++++++ src/pages/GDPR.tsx | 334 +++++++++ src/pages/InvitePage.module.css | 40 ++ src/pages/InvitePage.tsx | 255 +++---- src/pages/Login.module.css | 19 + src/pages/Login.tsx | 38 +- src/pages/Register.module.css | 19 + src/pages/Register.tsx | 33 +- src/pages/Settings.module.css | 7 + src/pages/Settings.tsx | 20 + src/pages/admin/AdminInvitationsPage.tsx | 20 +- .../admin/AdminUserAccessOverviewPage.tsx | 658 ++++++++++++++++++ src/pages/admin/index.ts | 3 +- .../trustee/TrusteeExpenseImportView.tsx | 658 ++++++++++++++++++ .../views/trustee/TrusteeViews.module.css | 346 ++++++++- src/pages/views/trustee/index.ts | 1 + src/types/mandate.ts | 1 + 24 files changed, 2541 insertions(+), 237 deletions(-) create mode 100644 src/pages/GDPR.module.css create mode 100644 src/pages/GDPR.tsx create mode 100644 src/pages/admin/AdminUserAccessOverviewPage.tsx create mode 100644 src/pages/views/trustee/TrusteeExpenseImportView.tsx diff --git a/src/App.tsx b/src/App.tsx index 24d5eb7..0f9f8af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ * URL-Struktur: * - / → Dashboard/Übersicht * - /settings → Benutzer-Einstellungen + * - /gdpr → GDPR / Datenschutz * - /mandates/:mandateId/:featureCode/:instanceId/* → Feature-Instanz-Routen * - /admin/* → System-Administration (nur SysAdmin) */ @@ -38,8 +39,9 @@ import { FeatureLayout } from './layouts/FeatureLayout'; // Pages import { DashboardPage } from './pages/Dashboard'; import { SettingsPage } from './pages/Settings'; +import { GDPRPage } from './pages/GDPR'; import { FeatureViewPage } from './pages/FeatureView'; -import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage } from './pages/admin'; +import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin'; // Workflow Pages (global) import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; @@ -103,6 +105,7 @@ function App() { {/* System-Seiten (ohne Instanz-Kontext) */} } /> + } /> {/* ============================================== */} {/* WORKFLOWS ROUTES (global) */} @@ -150,6 +153,7 @@ function App() { } /> } /> } /> + } /> } /> {/* Catch-all für unbekannte Sub-Pfade */} @@ -169,6 +173,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/api.ts b/src/api.ts index fa1ddb9..4a37764 100644 --- a/src/api.ts +++ b/src/api.ts @@ -126,11 +126,19 @@ api.interceptors.response.use( error.config?.url?.includes('/api/local/login') || error.config?.url?.includes('/api/msft/login'); - // Don't redirect if we're already on the login page (prevents redirect loops) - const isOnLoginPage = window.location.pathname === '/login' || - window.location.pathname.startsWith('/login'); + // Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work) + const pathname = window.location.pathname; + const isOnPublicAuthPage = pathname === '/login' || + pathname.startsWith('/login') || + pathname === '/register' || + pathname.startsWith('/register') || + pathname === '/reset' || + pathname.startsWith('/reset') || + pathname === '/password-reset-request' || + pathname.startsWith('/password-reset-request') || + pathname.startsWith('/invite'); - if (!isLoginEndpoint && !isOnLoginPage) { + if (!isLoginEndpoint && !isOnPublicAuthPage) { // Clear local auth data (httpOnly cookies are cleared by backend) sessionStorage.removeItem('auth_authority'); clearUserDataCache(); diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx index 7566418..e96d403 100644 --- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -186,9 +186,13 @@ export function FormGeneratorForm>({ filtered = filterFields(filtered); } else { // Default filtering based on mode + // Note: readonly fields (editable === false) should be shown but rendered as read-only + // Only hide fields where visible === false explicitly if (mode === 'edit') { - filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false); + // Show all visible fields (readonly fields are rendered as non-editable in renderField) + filtered = filtered.filter(attr => attr.visible !== false); } else if (mode === 'create') { + // In create mode, hide truly non-editable fields (user can't set them) filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false); } else if (mode === 'display') { filtered = filtered.filter(attr => attr.visible !== false); @@ -718,11 +722,17 @@ export function FormGeneratorForm>({ const option = options.find(opt => String(opt.value) === String(v)); return option ? option.label : v; }).join(', ') || t('common.none', 'None'); + } else if (typeof value === 'object' && value !== null) { + // Convert objects/arrays to formatted JSON string for display + displayValue = JSON.stringify(value, null, 2); } + // Use pre tag for JSON/object values to preserve formatting + const isJsonValue = typeof value === 'object' && value !== null; + return (
-
+
{displayValue || t('common.na', 'N/A')}
@@ -186,6 +157,12 @@ export const InvitePage: React.FC = () => {
+ {validation.mandateName && ( +
+ Mandant: + {validation.mandateName} +
+ )}
Status: Angemeldet @@ -227,13 +204,32 @@ export const InvitePage: React.FC = () => { ); } - // Not authenticated - show registration form or login option + // Not authenticated - show login/register options (NO inline registration form) return (

Einladung annehmen

-

Erstellen Sie ein Konto, um die Einladung anzunehmen.

+

Sie wurden eingeladen, einem Mandanten beizutreten.

+
+ +
+ {validation.mandateName && ( +
+ Mandant: + {validation.mandateName} +
+ )} + {validation.roleLabels && validation.roleLabels.length > 0 && ( +
+ Zugewiesene Rollen: + {validation.roleLabels.join(', ')} +
+ )} +
+ +
+

Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.

{error && ( @@ -242,120 +238,31 @@ export const InvitePage: React.FC = () => {
)} -
-
-
- - -
-
- - -
+
+ + +
+ oder
-
- - -
- -
- - -
- -
- - -
- -
- - setConfirmPassword(e.target.value)} - placeholder="••••••••" - required - /> -
- -
- -
- - -
- oder +
-
-

Sie haben bereits ein Konto?

- - Anmelden und Einladung annehmen - +
+

+ Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen. + Die Einladung wird automatisch nach der Anmeldung akzeptiert. +

diff --git a/src/pages/Login.module.css b/src/pages/Login.module.css index 0744cee..bf03ad9 100644 --- a/src/pages/Login.module.css +++ b/src/pages/Login.module.css @@ -264,6 +264,25 @@ button:disabled { font-family: var(--font-family); } +.invitationNotice { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background-color: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 8px; + margin-bottom: 16px; + font-size: 0.9rem; + color: #93c5fd; +} + +.invitationIcon { + flex-shrink: 0; + font-size: 1.2rem; + color: #3b82f6; +} + .passwordResetLink { display: flex; justify-content: center; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 28deeb4..bc44f83 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,9 +1,10 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { useState, useEffect } from 'react'; -import { FaGoogle, FaMicrosoft } from 'react-icons/fa'; +import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa'; import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; +import { PENDING_INVITATION_KEY } from './InvitePage'; import styles from './Login.module.css'; @@ -19,6 +20,10 @@ function Login() { const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); + // Check for pending invitation + const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY); + const hasPendingInvitation = !!pendingInvitationToken; + // Get the page the user was trying to visit const from = location.state?.from?.pathname || "/"; @@ -50,13 +55,23 @@ function Login() { return () => clearTimeout(timer); }, []); + + // Handle redirect after successful login + const handleSuccessfulLogin = () => { + // If there's a pending invitation, redirect to accept it + if (pendingInvitationToken) { + navigate(`/invite/${pendingInvitationToken}`, { replace: true }); + } else { + navigate(from, { replace: true }); + } + }; const handleMsalLogin = async () => { try { console.log("Attempting MSAL login..."); const response = await loginWithMsal(); console.log("MSAL login successful:", response); - navigate(from, { replace: true }); + handleSuccessfulLogin(); } catch (error) { console.error("MSAL login failed:", error); } @@ -67,7 +82,7 @@ function Login() { console.log("Attempting Google login..."); const response = await loginWithGoogle(); console.log("Google login successful:", response); - navigate(from, { replace: true }); + handleSuccessfulLogin(); } catch (error) { console.error("Google login failed:", error); } @@ -78,9 +93,8 @@ function Login() { try { console.log("Attempting login with:", username); await login(username, password); - console.log("Login successful, navigating to:", from); - // Only navigate if login was successful - navigate(from, { replace: true }); + console.log("Login successful"); + handleSuccessfulLogin(); } catch (error) { console.error("Login failed:", error); // Stay on login page to show error message @@ -100,6 +114,14 @@ function Login() {
+ {/* Pending invitation notice */} + {hasPendingInvitation && ( +
+ + Sie haben eine ausstehende Einladung. Bitte melden Sie sich an, um diese anzunehmen. +
+ )} + {(loginError || msalError || googleError) && (
{loginError || msalError || googleError}
)} @@ -191,7 +213,7 @@ function Login() { Du hast noch keinen Konto? @@ -204,4 +226,4 @@ function Login() { ); } -export default Login; \ No newline at end of file +export default Login; diff --git a/src/pages/Register.module.css b/src/pages/Register.module.css index 4112bb0..d78ff3e 100644 --- a/src/pages/Register.module.css +++ b/src/pages/Register.module.css @@ -240,6 +240,25 @@ button:disabled { margin-bottom: 10px; } +.invitationNotice { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background-color: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 12px; + margin-bottom: 16px; + font-size: 0.85rem; + color: #93c5fd; +} + +.invitationIcon { + flex-shrink: 0; + font-size: 1.2rem; + color: #3b82f6; +} + .infoMessage { background-color: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.3); diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 3072fdb..5b40aca 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,9 +1,11 @@ import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { FaEnvelopeOpenText } from 'react-icons/fa'; import styles from './Register.module.css'; import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; +import { PENDING_INVITATION_KEY } from './InvitePage'; interface RegisterFormData { username: string; @@ -13,6 +15,7 @@ interface RegisterFormData { function Register() { const navigate = useNavigate(); + const location = useLocation(); const { register, error: registerError, isLoading } = useRegister(); const { error: msalError } = useMsalRegister(); const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); @@ -28,6 +31,10 @@ function Register() { const [fullNameFocused, setFullNameFocused] = useState(false); const [usernameHighlight, setUsernameHighlight] = useState(false); + // Check for pending invitation + const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY); + const hasPendingInvitation = !!pendingInvitationToken; + // Set page title and generate CSRF token useEffect(() => { document.title = "PowerOn AI Platform - Registrieren"; @@ -89,15 +96,23 @@ function Register() { // Username is available, proceed with registration (no password - magic link flow) await register(formData); + // Build success message + let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'; + if (hasPendingInvitation) { + message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.'; + } + // Show success message instead of immediate redirect - setSuccessMessage('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'); + setSuccessMessage(message); // Redirect to login page after delay setTimeout(() => { navigate('/login', { state: { registered: true, - message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.' + message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.', + // Pass along invitation state + ...(location.state || {}) } }); }, 5000); @@ -127,6 +142,14 @@ function Register() {
+ {/* Pending invitation notice */} + {hasPendingInvitation && !successMessage && ( +
+ + Sie haben eine ausstehende Einladung. Nach der Registrierung und Anmeldung können Sie diese annehmen. +
+ )} + {getErrorMessage() && (
{getErrorMessage()}
)} @@ -203,7 +226,7 @@ function Register() { Bereits registriert? @@ -216,4 +239,4 @@ function Register() { ); } -export default Register; \ No newline at end of file +export default Register; diff --git a/src/pages/Settings.module.css b/src/pages/Settings.module.css index ee52d34..2631d2a 100644 --- a/src/pages/Settings.module.css +++ b/src/pages/Settings.module.css @@ -154,6 +154,13 @@ transition: all 0.2s ease; } +.linkButton { + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + .button:hover { background: var(--surface-color, #f5f5f5); border-color: var(--border-color, #c0c0c0); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7aa0818..cd215e1 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,6 +5,7 @@ */ import React, { useState, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { useLanguage } from '../providers/language/LanguageContext'; import { useCurrentUser, useUser } from '../hooks/useUsers'; import { setUserDataCache, getUserDataCache } from '../utils/userCache'; @@ -320,6 +321,25 @@ export const SettingsPage: React.FC = () => { )} + {/* Datenschutz */} +
+

Datenschutz

+ +
+
+ +

+ Data export, portability and account deletion. +

+
+
+ + Open GDPR page + +
+
+
+ {/* Info */}

Über

diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 881e340..06d8887 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -179,15 +179,15 @@ export const AdminInvitationsPage: React.FC = () => { } }; - // Handle revoke invitation - const handleRevokeInvitation = async (invitation: Invitation) => { - if (!selectedMandateId) return; - if (window.confirm('Möchten Sie diese Einladung wirklich widerrufen?')) { - const result = await revokeInvitation(selectedMandateId, invitation.id); - if (!result.success) { - alert(result.error || 'Fehler beim Widerrufen der Einladung'); - } + // Handle delete invitation by ID (for DeleteActionButton) + // Note: DeleteActionButton handles confirmation UI, so no window.confirm here + const handleDeleteInvitation = async (invitationId: string): Promise => { + if (!selectedMandateId) return false; + const result = await revokeInvitation(selectedMandateId, invitationId); + if (!result.success) { + alert(result.error || 'Fehler beim Widerrufen der Einladung'); } + return result.success; }; // Handle show URL @@ -352,9 +352,9 @@ export const AdminInvitationsPage: React.FC = () => { title: 'Einladungs-Link anzeigen', } ]} - onDelete={handleRevokeInvitation} hookData={{ - refetch: fetchInvitations, + handleDelete: handleDeleteInvitation, + refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }), pagination, }} emptyMessage="Keine Einladungen gefunden" diff --git a/src/pages/admin/AdminUserAccessOverviewPage.tsx b/src/pages/admin/AdminUserAccessOverviewPage.tsx new file mode 100644 index 0000000..f509f6c --- /dev/null +++ b/src/pages/admin/AdminUserAccessOverviewPage.tsx @@ -0,0 +1,658 @@ +/** + * AdminUserAccessOverviewPage + * + * Admin page for viewing comprehensive user access permissions. + * Shows what pages a user can see and what data they can access. + */ + +import React, { useState, useEffect } from 'react'; +import { FaSync, FaUserShield, FaEye, FaDatabase, FaCube, FaChevronDown, FaChevronRight, FaCheckCircle, FaTimesCircle, FaInfoCircle } from 'react-icons/fa'; +import api from '../../api'; +import styles from './Admin.module.css'; + +interface UserOption { + id: string; + username: string; + email: string; + fullName: string; + isSysAdmin: boolean; + enabled: boolean; +} + +interface RoleInfo { + id: string; + roleLabel: string; + description: { [key: string]: string }; + scope: 'global' | 'mandate' | 'instance'; + mandateId?: string; + featureInstanceId?: string; + source: string; + sourceMandateId?: string; + sourceMandateName?: string; + sourceInstanceId?: string; + sourceInstanceLabel?: string; +} + +interface AccessEntry { + item: string; + view?: boolean; + read?: string; + create?: string; + update?: string; + delete?: string; + grantedByRoleLabels: string[]; + roleScope: string; +} + +interface MandateInfo { + id: string; + name: string; + roleIds: string[]; + featureInstances: { + id: string; + label: string; + featureCode: string; + featureLabel: { [key: string]: string }; + roleIds: string[]; + }[]; +} + +interface UserAccessOverview { + user: UserOption; + isSysAdmin: boolean; + sysAdminNote?: string; + roles: RoleInfo[]; + mandates: MandateInfo[]; + uiAccess: AccessEntry[]; + dataAccess: AccessEntry[]; + resourceAccess: AccessEntry[]; +} + +type TabId = 'overview' | 'ui' | 'data' | 'resources'; + +export const AdminUserAccessOverviewPage: React.FC = () => { + const [users, setUsers] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(''); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingUsers, setLoadingUsers] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + const [expandedRoles, setExpandedRoles] = useState>(new Set()); + const [expandedMandates, setExpandedMandates] = useState>(new Set()); + + // Fetch users list on mount + useEffect(() => { + const fetchUsers = async () => { + try { + setLoadingUsers(true); + const response = await api.get('/api/admin/user-access-overview/users'); + setUsers(response.data); + } catch (err: any) { + setError(err?.response?.data?.detail || err?.message || 'Failed to fetch users'); + } finally { + setLoadingUsers(false); + } + }; + + fetchUsers(); + }, []); + + // Fetch access overview when user is selected + useEffect(() => { + if (!selectedUserId) { + setOverview(null); + return; + } + + const fetchOverview = async () => { + try { + setLoading(true); + setError(null); + + const response = await api.get(`/api/admin/user-access-overview/${selectedUserId}`); + const data = response.data; + setOverview(data); + + // Auto-expand all mandates + setExpandedMandates(new Set(data.mandates?.map((m: MandateInfo) => m.id) || [])); + } catch (err: any) { + setError(err?.response?.data?.detail || err?.message || 'Failed to fetch overview'); + setOverview(null); + } finally { + setLoading(false); + } + }; + + fetchOverview(); + }, [selectedUserId]); + + const toggleRole = (roleId: string) => { + setExpandedRoles(prev => { + const newSet = new Set(prev); + if (newSet.has(roleId)) { + newSet.delete(roleId); + } else { + newSet.add(roleId); + } + return newSet; + }); + }; + + const toggleMandate = (mandateId: string) => { + setExpandedMandates(prev => { + const newSet = new Set(prev); + if (newSet.has(mandateId)) { + newSet.delete(mandateId); + } else { + newSet.add(mandateId); + } + return newSet; + }); + }; + + const getScopeColor = (scope: string): string => { + switch (scope) { + case 'instance': return '#10b981'; + case 'mandate': return '#3b82f6'; + case 'global': return '#8b5cf6'; + default: return '#6b7280'; + } + }; + + const getAccessLevelColor = (level: string): string => { + switch (level) { + case 'ALL': return '#10b981'; + case 'GROUP': return '#3b82f6'; + case 'MY': return '#f59e0b'; + case 'NONE': return '#ef4444'; + default: return '#6b7280'; + } + }; + + const renderOverviewTab = () => { + if (!overview) return null; + + return ( +
+ {/* SysAdmin Notice */} + {overview.isSysAdmin && ( +
+ + {overview.sysAdminNote || 'Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.'} +
+ )} + + {/* Mandates & Feature Instances */} +

Mandate & Feature-Instanzen

+ {overview.mandates.length === 0 ? ( +

Keine Mandate-Zuordnungen vorhanden.

+ ) : ( +
+ {overview.mandates.map(mandate => ( +
+
toggleMandate(mandate.id)} + > +
+ {expandedMandates.has(mandate.id) ? : } + {mandate.name} + + {mandate.featureInstances.length} Feature-Instanz(en) + +
+
+ {expandedMandates.has(mandate.id) && ( +
+ {mandate.featureInstances.length === 0 ? ( +

Keine Feature-Instanzen zugewiesen.

+ ) : ( +
+ {mandate.featureInstances.map(instance => ( +
+
+ {instance.label} +
+
+ Feature: {instance.featureLabel?.de || instance.featureCode} +
+
+ Rollen: {instance.roleIds.length > 0 + ? overview.roles + .filter(r => instance.roleIds.includes(r.id)) + .map(r => r.roleLabel) + .join(', ') + : 'Keine' + } +
+
+ ))} +
+ )} +
+ )} +
+ ))} +
+ )} + + {/* Roles */} +

Zugewiesene Rollen

+ {overview.roles.length === 0 ? ( +

Keine Rollen zugewiesen.

+ ) : ( +
+ {overview.roles.map(role => ( +
+
toggleRole(role.id)} + > +
+ {expandedRoles.has(role.id) ? : } + {role.roleLabel} + + {role.scope} + +
+
+ {expandedRoles.has(role.id) && ( +
+
+

Beschreibung: {role.description?.de || role.description?.en || '-'}

+

Quelle: {role.source === 'mandate' + ? `Mandate: ${role.sourceMandateName}` + : `Feature-Instanz: ${role.sourceInstanceLabel}` + }

+
+
+ )} +
+ ))} +
+ )} +
+ ); + }; + + const renderUiAccessTab = () => { + if (!overview) return null; + + return ( +
+
+ + UI-Zugriffsrechte bestimmen, welche Seiten und Views der Benutzer sehen kann. +
+ + {overview.uiAccess.length === 0 ? ( +
+ +

Keine UI-Berechtigungen

+

+ Diesem Benutzer wurden keine expliziten UI-Berechtigungen zugewiesen. +

+
+ ) : ( + + + + + + + + + + {overview.uiAccess.map((entry, idx) => ( + + + + + + ))} + +
UI-ElementSichtbarGewährt durch
+ {entry.item} + + {entry.view ? ( + + ) : ( + + )} + + {entry.grantedByRoleLabels?.join(', ') || '-'} +
+ )} +
+ ); + }; + + const renderDataAccessTab = () => { + if (!overview) return null; + + return ( +
+
+ + + Daten-Zugriffsrechte: ALL = Alle Datensätze, GROUP = Gruppen-Datensätze, + MY = Eigene Datensätze, NONE = Kein Zugriff + +
+ + {overview.dataAccess.length === 0 ? ( +
+ +

Keine Daten-Berechtigungen

+

+ Diesem Benutzer wurden keine expliziten Daten-Berechtigungen zugewiesen. +

+
+ ) : ( + + + + + + + + + + + + + {overview.dataAccess.map((entry, idx) => ( + + + + + + + + + ))} + +
Tabelle/FeldLesenErstellenUpdateLöschenGewährt durch
+ {entry.item} + + + {entry.read || '-'} + + + + {entry.create || '-'} + + + + {entry.update || '-'} + + + + {entry.delete || '-'} + + + {entry.grantedByRoleLabels?.join(', ') || '-'} +
+ )} +
+ ); + }; + + const renderResourceAccessTab = () => { + if (!overview) return null; + + return ( +
+
+ + Ressourcen-Zugriffsrechte bestimmen, welche System-Ressourcen (z.B. AI-Modelle) der Benutzer verwenden kann. +
+ + {overview.resourceAccess.length === 0 ? ( +
+ +

Keine Ressourcen-Berechtigungen

+

+ Diesem Benutzer wurden keine expliziten Ressourcen-Berechtigungen zugewiesen. +

+
+ ) : ( + + + + + + + + + + {overview.resourceAccess.map((entry, idx) => ( + + + + + + ))} + +
RessourceZugriffGewährt durch
+ {entry.item} + + {entry.view ? ( + + ) : ( + + )} + + {entry.grantedByRoleLabels?.join(', ') || '-'} +
+ )} +
+ ); + }; + + if (error && !overview) { + return ( +
+
+ ⚠️ +

Fehler: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Benutzer-Zugriffsübersicht

+

Zeigt alle Berechtigungen eines Benutzers an

+
+
+ + {/* User Selection */} +
+
+ + +
+ + {selectedUserId && ( + + )} +
+ + {/* Content */} + {!selectedUserId ? ( +
+ +

Benutzer auswählen

+

+ Wählen Sie einen Benutzer aus, um dessen Zugriffsberechtigungen anzuzeigen. +

+
+ ) : loading ? ( +
+
+ Lade Zugriffsübersicht... +
+ ) : overview ? ( + <> + {/* User Info */} +
+ {overview.user.fullName || overview.user.username} + | + {overview.user.email} + {overview.isSysAdmin && ( + <> + | + + SysAdmin + + + )} +
+ + {/* Tabs */} +
+ + + + +
+ + {/* Tab Content */} +
+ {activeTab === 'overview' && renderOverviewTab()} + {activeTab === 'ui' && renderUiAccessTab()} + {activeTab === 'data' && renderDataAccessTab()} + {activeTab === 'resources' && renderResourceAccessTab()} +
+ + ) : null} +
+ ); +}; + +export default AdminUserAccessOverviewPage; diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index d0405a3..217597b 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -12,4 +12,5 @@ export { AdminInvitationsPage } from './AdminInvitationsPage'; export { AdminMandateRolesPage } from './AdminMandateRolesPage'; export { AdminFeatureRolesPage } from './AdminFeatureRolesPage'; export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage'; -export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage'; \ No newline at end of file +export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage'; +export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; \ No newline at end of file diff --git a/src/pages/views/trustee/TrusteeExpenseImportView.tsx b/src/pages/views/trustee/TrusteeExpenseImportView.tsx new file mode 100644 index 0000000..1181b14 --- /dev/null +++ b/src/pages/views/trustee/TrusteeExpenseImportView.tsx @@ -0,0 +1,658 @@ +/** + * TrusteeExpenseImportView + * + * Setup page for automatic expense import from SharePoint PDFs. + * Allows users to connect their Microsoft account, select a SharePoint folder, + * and activate daily automation for expense extraction. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; +import { useConnections } from '../../../hooks/useConnections'; +import { useToast } from '../../../contexts/ToastContext'; +import api from '../../../api'; +import styles from './TrusteeViews.module.css'; + +// Default extraction prompt (from automation template) +const DEFAULT_EXTRACTION_PROMPT = `Du bist ein Spezialist für die Extraktion von Spesendaten aus PDF-Dokumenten. + +AUFGABE: +Extrahiere alle Speseneinträge aus dem bereitgestellten PDF-Dokument und gib sie im CSV-Format zurück. + +WICHTIGE REGELN: +1. Pro MwSt-Prozentsatz einen separaten Datensatz erstellen +2. Alle Datensätze zusammen müssen den Gesamtbetrag des Dokuments ergeben +3. Der gesamte extrahierte Text des Dokuments muss im Feld "desc" erfasst werden +4. Feld "company" enthält den Lieferanten/Verkäufer der Buchung +5. Tags müssen aus dieser Liste gewählt werden: customer, meeting, license, subscription, fuel, food, material + - Mehrere zutreffende Tags mit Komma trennen + +CSV-SPALTEN (in dieser Reihenfolge): +valuta,transactionDateTime,company,desc,tags,bookingCurrency,bookingAmount,originalCurrency,originalAmount,vatPercentage,vatAmount + +DATENFORMAT: +- valuta: YYYY-MM-DD (Valutadatum) +- transactionDateTime: Unix-Timestamp in Sekunden (Transaktionszeitpunkt) +- company: Lieferant/Verkäufer Name +- desc: Vollständiger extrahierter Text des Dokuments +- tags: Komma-getrennte Tags aus der erlaubten Liste +- bookingCurrency: Währungscode (CHF, EUR, USD, GBP) +- bookingAmount: Buchungsbetrag als Dezimalzahl +- originalCurrency: Original-Währungscode +- originalAmount: Original-Betrag als Dezimalzahl +- vatPercentage: MwSt-Prozentsatz (z.B. 8.1 für 8.1%) +- vatAmount: MwSt-Betrag als Dezimalzahl + +HINWEISE: +- Wenn nur ein MwSt-Satz vorhanden ist, einen Datensatz erstellen +- Wenn mehrere MwSt-Sätze vorhanden sind, separate Datensätze erstellen +- Bei fehlenden Informationen: leeres Feld oder Standardwert`; + +interface SiteOption { + value: string; + label: string; + siteId: string; + siteName: string; + webUrl: string; + path: string; +} + +interface FolderOption { + value: string; + label: string; + siteId: string; + folderName: string; + path: string; +} + +interface Connection { + id: string; + type?: string; + authority: string; + status: string; + externalUsername?: string; + accountName?: string; // Legacy fallback +} + +interface ExistingAutomation { + id: string; + label: string; + active: boolean; + schedule: string; + placeholders: Record; +} + +// Helper function to safely convert error detail to string +const parseErrorDetail = (detail: any): string => { + if (typeof detail === 'string') return detail; + if (Array.isArray(detail)) { + // FastAPI validation errors come as array of {type, loc, msg, input} + return detail.map(e => e.msg || JSON.stringify(e)).join(', '); + } + if (typeof detail === 'object' && detail !== null) { + return detail.msg || detail.message || JSON.stringify(detail); + } + return String(detail); +}; + +export const TrusteeExpenseImportView: React.FC = () => { + // Use instanceId/mandateId from URL params (always available) + const { instanceId, mandateId } = useCurrentInstance(); + const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections(); + const { showSuccess, showError } = useToast(); + + const [msftConnections, setMsftConnections] = useState([]); + const [msftConnection, setMsftConnection] = useState(null); + const [siteOptions, setSiteOptions] = useState([]); + const [folderOptions, setFolderOptions] = useState([]); + const [selectedSite, setSelectedSite] = useState(null); + const [currentPath, setCurrentPath] = useState(''); + const [selectedFolder, setSelectedFolder] = useState(''); + const [isLoadingSites, setIsLoadingSites] = useState(false); + const [isLoadingFolders, setIsLoadingFolders] = useState(false); + const [isActivating, setIsActivating] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [existingAutomation, setExistingAutomation] = useState(null); + const [isLoadingAutomation, setIsLoadingAutomation] = useState(true); + const [showInfoTooltip, setShowInfoTooltip] = useState(false); + + // Find all active Microsoft connections + useEffect(() => { + const msftConns = connections.filter((c: Connection) => + (c.type === 'msft' || c.authority === 'msft') && c.status === 'active' + ); + setMsftConnections(msftConns); + + // Auto-select if only one connection + if (msftConns.length === 1) { + setMsftConnection(msftConns[0]); + } else if (msftConns.length === 0) { + setMsftConnection(null); + } + // Note: When multiple connections exist, user must select manually + }, [connections]); + + // Load existing automation for this feature instance + useEffect(() => { + const loadExistingAutomation = async () => { + if (!instanceId) return; + + setIsLoadingAutomation(true); + try { + // Fetch all automations and filter client-side + const response = await api.get('/api/automations'); + + const automations = response.data?.items || response.data?.data || response.data || []; + // Find automation by label AND featureInstanceId + const expenseAutomation = automations.find((a: any) => + a.label === 'Expense Import' && a.featureInstanceId === instanceId + ); + + if (expenseAutomation) { + setExistingAutomation({ + id: expenseAutomation.id, + label: expenseAutomation.label, + active: expenseAutomation.active, + schedule: expenseAutomation.schedule, + placeholders: expenseAutomation.placeholders || {} + }); + // Pre-fill selected folder from existing automation + if (expenseAutomation.placeholders?.sharepointFolder) { + setSelectedFolder(expenseAutomation.placeholders.sharepointFolder); + } + } + } catch (err) { + console.error('Failed to load existing automation:', err); + } finally { + setIsLoadingAutomation(false); + } + }; + + loadExistingAutomation(); + }, [instanceId]); + + // Pre-select connection from existing automation when connections are loaded + useEffect(() => { + if (!existingAutomation || msftConnections.length === 0 || msftConnection) return; + + // Try to match connection from existing automation placeholders + // Format: "connection:msft:externalUsername" or just the connection ID + const savedConnectionRef = existingAutomation.placeholders?.connectionName || ''; + + // Try to find matching connection by externalUsername, accountName, or id + const matchingConn = msftConnections.find(c => + savedConnectionRef.includes(c.externalUsername || '') || + savedConnectionRef.includes(c.accountName || '') || + savedConnectionRef.includes(c.id) + ); + + if (matchingConn) { + setMsftConnection(matchingConn); + } else if (msftConnections.length === 1) { + // Fallback to single connection + setMsftConnection(msftConnections[0]); + } + }, [existingAutomation, msftConnections, msftConnection]); + + // Load SharePoint sites when connected + const loadSiteOptions = useCallback(async () => { + if (!msftConnection) return; + + setIsLoadingSites(true); + setError(null); + + try { + const response = await api.get(`/api/sharepoint/${msftConnection.id}/folder-options`); + setSiteOptions(response.data || []); + } catch (err: any) { + console.error('Failed to load sites:', err); + setError(parseErrorDetail(err.response?.data?.detail) || 'Failed to load SharePoint sites'); + setSiteOptions([]); + } finally { + setIsLoadingSites(false); + } + }, [msftConnection]); + + // Load folders when site is selected + const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => { + if (!msftConnection || !siteId) return; + + setIsLoadingFolders(true); + setError(null); + + try { + const params = new URLSearchParams({ siteId }); + if (path) params.append('path', path); + + const response = await api.get(`/api/sharepoint/${msftConnection.id}/folder-options?${params}`); + setFolderOptions(response.data || []); + } catch (err: any) { + console.error('Failed to load folders:', err); + setError(parseErrorDetail(err.response?.data?.detail) || 'Failed to load folders'); + setFolderOptions([]); + } finally { + setIsLoadingFolders(false); + } + }, [msftConnection]); + + useEffect(() => { + if (msftConnection) { + loadSiteOptions(); + } + }, [msftConnection, loadSiteOptions]); + + // Load root folders when site changes + useEffect(() => { + if (selectedSite) { + setCurrentPath(''); + setSelectedFolder(''); + loadFolderOptions(selectedSite.siteId, ''); + } + }, [selectedSite, loadFolderOptions]); + + const handleSiteChange = (siteId: string) => { + const site = siteOptions.find(s => s.siteId === siteId); + setSelectedSite(site || null); + }; + + const handleFolderNavigate = (folder: FolderOption) => { + const newPath = folder.path; + setCurrentPath(newPath); + loadFolderOptions(selectedSite!.siteId, newPath); + }; + + const handleFolderSelect = (folder: FolderOption) => { + // Build full path: /sites/SiteName/FolderPath + const fullPath = `${selectedSite?.path || ''}/${folder.path}`; + setSelectedFolder(fullPath); + }; + + const handleGoUp = () => { + if (!currentPath) return; + const parts = currentPath.split('/'); + parts.pop(); + const parentPath = parts.join('/'); + setCurrentPath(parentPath); + loadFolderOptions(selectedSite!.siteId, parentPath); + }; + + const handleConnect = async () => { + setIsConnecting(true); + setError(null); + + try { + await createMicrosoftConnectionAndAuth(); + await fetchConnections(); + } catch (err: any) { + console.error('Connection failed:', err); + setError(err.message || 'Microsoft connection failed'); + } finally { + setIsConnecting(false); + } + }; + + const handleSave = async (activate: boolean = true) => { + // Validate required fields with user feedback + if (!msftConnection) { + showError('Missing Connection', 'Please select a Microsoft connection first.'); + return; + } + if (!selectedFolder) { + showError('Missing Folder', 'Please select a SharePoint folder first.'); + return; + } + if (!instanceId || !mandateId) { + showError('Error', 'Feature instance not found. Please refresh the page.'); + return; + } + + setIsActivating(true); + setError(null); + setSuccessMessage(null); + + try { + const connectionReference = `connection:msft:${msftConnection.externalUsername || msftConnection.accountName || msftConnection.id}`; + + const automationData = { + label: 'Expense Import', + schedule: '0 22 * * *', // Daily at 22:00 + template: JSON.stringify({ + overview: "Expenses PDF to Trustee Position", + tasks: [{ + id: "Task01", + title: "Extract Expenses from SharePoint PDFs", + description: "Automatic expense extraction", + objective: "Extract expense data from PDF documents", + actionList: [{ + execMethod: "sharepoint", + execAction: "getExpensesFromPdf", + execParameters: { + connectionReference: connectionReference, + sharepointFolder: selectedFolder, + featureInstanceId: instanceId, + prompt: DEFAULT_EXTRACTION_PROMPT + }, + execResultLabel: "expense_extraction_result" + }] + }] + }), + placeholders: { + connectionName: connectionReference, + sharepointFolder: selectedFolder, + featureInstanceId: instanceId + }, + active: activate, + mandateId: mandateId, + featureInstanceId: instanceId + }; + + let response; + if (existingAutomation) { + // Update existing automation + response = await api.put(`/api/automations/${existingAutomation.id}`, { + ...automationData, + id: existingAutomation.id, + mandateId: mandateId + }); + const msg = activate + ? 'Expense import automation updated and activated!' + : 'Expense import automation updated and deactivated.'; + setSuccessMessage(msg); + showSuccess('Success', msg); + } else { + // Create new automation + response = await api.post('/api/automations', automationData); + const msg = 'Expense import automation created and activated! It will run daily at 22:00.'; + setSuccessMessage(msg); + showSuccess('Success', msg); + } + + // Update local state with response + const savedAutomation = response.data; + setExistingAutomation({ + id: savedAutomation.id, + label: savedAutomation.label, + active: savedAutomation.active, + schedule: savedAutomation.schedule, + placeholders: savedAutomation.placeholders || {} + }); + + } catch (err: any) { + console.error('Save failed:', err); + const errorMsg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to save automation'; + setError(errorMsg); + showError('Error', errorMsg); + } finally { + setIsActivating(false); + } + }; + + const handleDeactivate = async () => { + if (!existingAutomation) return; + + setIsActivating(true); + setError(null); + + try { + // Use dedicated PATCH endpoint for status changes + await api.patch(`/api/automations/${existingAutomation.id}/status`, { + active: false + }); + + setExistingAutomation(prev => prev ? { ...prev, active: false } : null); + setSuccessMessage('Expense import automation deactivated.'); + showSuccess('Deactivated', 'Expense import automation deactivated.'); + } catch (err: any) { + console.error('Deactivation failed:', err); + const errorMsg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to deactivate automation'; + setError(errorMsg); + showError('Error', errorMsg); + } finally { + setIsActivating(false); + } + }; + + return ( +
+
+

Expense Import Setup

+

+ Connect your Microsoft account and select a SharePoint folder containing expense PDFs. + The system will automatically extract expense data daily and save it as positions. + setShowInfoTooltip(true)} + onMouseLeave={() => setShowInfoTooltip(false)} + onClick={() => setShowInfoTooltip(!showInfoTooltip)} + title="How it works" + > + ⓘ + + {showInfoTooltip && ( + + How it works: +

    +
  • Place expense PDF documents (receipts, invoices) in the selected SharePoint folder
  • +
  • The system runs daily at 22:00 and processes all PDF files
  • +
  • AI extracts expense data: date, amount, VAT, company, description
  • +
  • Each expense is saved as a position in this Trustee instance
  • +
  • Processed PDFs are moved to a "processed" subfolder
  • +
  • Failed PDFs are moved to an "error" subfolder
  • +
  • Maximum 50 PDFs are processed per run
  • +
+ + )} +

+ + {error && ( +
+ {error} +
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Current Status */} + {!isLoadingAutomation && existingAutomation && ( +
+ Current Status: {existingAutomation.active ? '✓ Active' : '○ Inactive'} + {existingAutomation.placeholders?.sharepointFolder && ( + <>
Folder: {existingAutomation.placeholders.sharepointFolder} + )} +
+ )} + + {/* Step 1: Microsoft Connection */} +
+
1
+
+

Microsoft Connection

+ {msftConnections.length === 0 ? ( + // No connections - show connect button + + ) : msftConnections.length === 1 ? ( + // Single connection - show as connected +
+ + + Connected as {msftConnections[0].accountName || 'Microsoft Account'} + +
+ ) : ( + // Multiple connections - show dropdown + <> + + + + )} +
+
+ + {/* Step 2: SharePoint Site Selection */} + {msftConnection && ( +
+
2
+
+

SharePoint Site

+ {isLoadingSites ? ( +
Loading sites...
+ ) : ( + + )} +
+
+ )} + + {/* Step 3: Folder Selection */} + {selectedSite && ( +
+
3
+
+

Expense Folder

+

+ Current path: {selectedSite.path}/{currentPath || '(root)'} +

+ {isLoadingFolders ? ( +
Loading folders...
+ ) : ( +
+
+ {currentPath && ( + + )} + +
+
+ {folderOptions.map((folder) => ( +
+ handleFolderNavigate(folder)} + style={{ cursor: 'pointer', flex: 1 }} + > + 📁 {folder.label} + + +
+ ))} + {folderOptions.length === 0 && ( +
No subfolders found
+ )} +
+ {selectedFolder && ( +

+ Selected: {selectedFolder} +

+ )} +
+ )} +
+
+ )} + + {/* Step 4: Save & Activate */} + {selectedFolder && ( +
+
4
+
+

{existingAutomation ? 'Update Configuration' : 'Activate Daily Import'}

+

+ PDF files in {selectedFolder} will be processed daily at 22:00. + Successfully processed files will be moved to a "processed" subfolder. +

+
+ + {existingAutomation && existingAutomation.active && ( + + )} +
+
+
+ )} + +
+
+ ); +}; + +export default TrusteeExpenseImportView; diff --git a/src/pages/views/trustee/TrusteeViews.module.css b/src/pages/views/trustee/TrusteeViews.module.css index eb3a037..09963e8 100644 --- a/src/pages/views/trustee/TrusteeViews.module.css +++ b/src/pages/views/trustee/TrusteeViews.module.css @@ -564,7 +564,8 @@ .infoBox { display: flex; - align-items: center; + flex-direction: column; + align-items: flex-start; padding: 0.75rem 1rem; background: var(--info-light, #e0f2fe); border: 1px solid var(--info-color, #0284c7); @@ -632,3 +633,346 @@ border-color: var(--info-color, #0284c7); color: var(--info-light, #e0f2fe); } + +/* ============================================================================= + * Expense Import View Styles + * ============================================================================= */ + +.expenseImportSection { + max-width: 800px; +} + +.sectionTitle { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + margin-bottom: 0.5rem; +} + +.sectionDescription { + color: var(--text-secondary, #666); + font-size: 0.9375rem; + margin-bottom: 1.5rem; + line-height: 1.5; + position: relative; +} + +/* Info Icon and Tooltip */ +.infoIcon { + display: inline-block; + margin-left: 0.5rem; + color: var(--info-color, #0284c7); + cursor: pointer; + font-size: 1rem; + vertical-align: middle; + transition: color 0.2s; +} + +.infoIcon:hover { + color: var(--primary-color, #3b82f6); +} + +.infoTooltip { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + z-index: 100; + background: var(--info-light, #e0f2fe); + border: 1px solid var(--info-color, #0284c7); + border-radius: 8px; + padding: 1rem; + color: var(--info-color, #0c4a6e); + font-size: 0.875rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: fadeIn 0.2s ease-out; +} + +.infoTooltip strong { + display: block; + margin-bottom: 0.5rem; + font-size: 0.9375rem; +} + +.infoTooltip ul { + margin: 0; + padding-left: 1.25rem; +} + +.infoTooltip li { + margin-bottom: 0.375rem; + line-height: 1.5; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +:global(.dark-theme) .infoTooltip { + background: var(--info-dark, #0c4a6e); + border-color: var(--info-color, #0284c7); + color: var(--info-light, #e0f2fe); +} + +.setupStep { + display: flex; + gap: 1rem; + padding: 1.25rem; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + margin-bottom: 1rem; +} + +.stepNumber { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + min-width: 32px; + background: var(--primary-color, #3b82f6); + color: white; + border-radius: 50%; + font-weight: 600; + font-size: 0.875rem; +} + +.stepContent { + flex: 1; +} + +.stepContent h4 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + margin: 0 0 0.75rem 0; +} + +.connectionStatus { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.connectedIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--success-light, #dcfce7); + color: var(--success-color, #16a34a); + border-radius: 50%; + font-weight: bold; + font-size: 0.75rem; +} + +.connectedText { + color: var(--text-primary, #1a1a1a); +} + +.folderSelect { + width: 100%; + max-width: 400px; + padding: 0.625rem 0.875rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.9375rem; + background: var(--bg-primary, #ffffff); + color: var(--text-primary, #1a1a1a); + margin-bottom: 0.75rem; +} + +.folderSelect:focus { + outline: none; + border-color: var(--primary-color, #3b82f6); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.activateDescription { + color: var(--text-secondary, #666); + font-size: 0.875rem; + margin-bottom: 1rem; + line-height: 1.5; +} + +.loadingText { + color: var(--text-secondary, #666); + font-style: italic; +} + +.errorMessage { + padding: 0.75rem 1rem; + background: var(--error-light, #fef2f2); + border: 1px solid var(--error-color, #dc2626); + border-radius: 6px; + color: var(--error-color, #dc2626); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.successMessage { + padding: 0.75rem 1rem; + background: var(--success-light, #dcfce7); + border: 1px solid var(--success-color, #16a34a); + border-radius: 6px; + color: var(--success-color, #16a34a); + font-size: 0.875rem; + margin-bottom: 1rem; +} + +.infoBox h4 { + font-size: 0.9375rem; + font-weight: 600; + margin: 0 0 0.5rem 0; +} + +.infoBox ul { + margin: 0; + padding-left: 1.25rem; +} + +.infoBox li { + margin-bottom: 0.375rem; + font-size: 0.875rem; + line-height: 1.5; +} + +/* Folder Browser */ +.folderBrowser { + margin-top: 0.5rem; +} + +.folderList { + max-height: 250px; + overflow-y: auto; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + background: var(--bg-primary, #ffffff); +} + +.folderItem { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); +} + +.folderItem:last-child { + border-bottom: none; +} + +.folderItem:hover { + background: var(--surface-color, #f8f9fa); +} + +.folderName { + flex: 1; + color: var(--text-primary, #1a1a1a); + font-size: 0.875rem; +} + +.folderName:hover { + color: var(--primary-color, #3b82f6); +} + +.selectButton { + padding: 0.25rem 0.5rem; + border: 1px solid var(--primary-color, #3b82f6); + border-radius: 4px; + background: transparent; + color: var(--primary-color, #3b82f6); + font-size: 0.75rem; + cursor: pointer; +} + +.selectButton:hover { + background: var(--primary-color, #3b82f6); + color: white; +} + +.emptyText { + padding: 1rem; + text-align: center; + color: var(--text-secondary, #666); + font-style: italic; +} + +.selectedFolderText { + margin-top: 0.75rem; + padding: 0.5rem; + background: var(--success-light, #dcfce7); + border-radius: 4px; + color: var(--success-color, #16a34a); + font-size: 0.875rem; +} + +/* Dark Theme - Folder Browser */ +:global(.dark-theme) .folderList { + background: var(--surface-dark, #2a2a2a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .folderItem { + border-bottom-color: var(--border-dark, #333); +} + +:global(.dark-theme) .folderItem:hover { + background: var(--surface-dark, #333); +} + +:global(.dark-theme) .folderName { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .selectedFolderText { + background: var(--success-dark, #052e16); + color: var(--success-light, #dcfce7); +} + +/* Dark Theme - Expense Import */ +:global(.dark-theme) .sectionTitle { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .sectionDescription, +:global(.dark-theme) .activateDescription { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .setupStep { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .stepContent h4 { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .connectedText { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .folderSelect { + background: var(--surface-dark, #2a2a2a); + border-color: var(--border-dark, #333); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .errorMessage { + background: var(--error-dark, #450a0a); + color: var(--error-light, #fef2f2); +} + +:global(.dark-theme) .successMessage { + background: var(--success-dark, #052e16); + color: var(--success-light, #dcfce7); +} diff --git a/src/pages/views/trustee/index.ts b/src/pages/views/trustee/index.ts index e76d081..753e993 100644 --- a/src/pages/views/trustee/index.ts +++ b/src/pages/views/trustee/index.ts @@ -10,3 +10,4 @@ export { TrusteeDocumentsView } from './TrusteeDocumentsView'; export { TrusteePositionsView } from './TrusteePositionsView'; export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView'; export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView'; +export { TrusteeExpenseImportView } from './TrusteeExpenseImportView'; diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 1fd1c2e..fee57e1 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -206,6 +206,7 @@ export const FEATURE_REGISTRY: Record = { { code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' }, { code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' }, { code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-documents' }, + { code: 'expense-import', label: { de: 'Spesen Import', en: 'Expense Import' }, path: 'expense-import' }, { code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true }, ] },