+ {/* 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.
+
+
+ ) : (
+
+
+
+ | UI-Element |
+ Sichtbar |
+ Gewährt durch |
+
+
+
+ {overview.uiAccess.map((entry, idx) => (
+
+ |
+ {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.
+
+
+ ) : (
+
+
+
+ | Tabelle/Feld |
+ Lesen |
+ Erstellen |
+ Update |
+ Löschen |
+ Gewährt durch |
+
+
+
+ {overview.dataAccess.map((entry, idx) => (
+
+ |
+ {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.
+
+
+ ) : (
+
+
+
+ | Ressource |
+ Zugriff |
+ Gewährt durch |
+
+
+
+ {overview.resourceAccess.map((entry, idx) => (
+
+ |
+ {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 },
]
},