integration view

This commit is contained in:
ValueOn AG 2026-04-12 18:32:16 +02:00
parent 4762818d3d
commit 88b581ac83
15 changed files with 2241 additions and 1275 deletions

View file

@ -37,6 +37,7 @@ import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
@ -97,6 +98,7 @@ function App() {
{/* System-Seiten (ohne Instanz-Kontext) */}
<Route path="store" element={<StorePage />} />
<Route path="integrations" element={<IntegrationsOverviewPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} />

View file

@ -107,10 +107,10 @@ export interface TrusteePosition {
export interface AccountingConnectorInfo {
connectorType: string;
label: Record<string, string>;
label: string;
configFields: Array<{
key: string;
label: Record<string, string>;
label: string;
fieldType: string;
secret: boolean;
required: boolean;
@ -873,3 +873,17 @@ export async function fetchSyncStatus(
method: 'get'
});
}
export async function exportAccountingData(
request: ApiRequestFunction,
instanceId: string
): Promise<void> {
const url = `${_getTrusteeBaseUrl(instanceId)}/accounting/export-data`;
const response = await request({ url, method: 'get' });
const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `trustee_data_${instanceId.slice(0, 8)}.json`;
link.click();
URL.revokeObjectURL(link.href);
}

View file

@ -321,12 +321,12 @@ export function FormGeneratorForm<T extends Record<string, any>>({
let fetchedOptions: Array<{ value: string | number; label: string }> = [];
if (Array.isArray(response.data)) {
// Backend returns standardized format: [{ value, label }]
fetchedOptions = response.data.map((opt: any) => {
if (typeof opt === 'string' || typeof opt === 'number') {
return { value: opt, label: String(opt) };
}
return { value: opt.value, label: opt.label || String(opt.value) };
const val = opt.value ?? opt.code ?? opt.id;
return { value: val, label: opt.label || String(val) };
});
}

View file

@ -1,507 +0,0 @@
/* =============================================================================
* RBAC Export/Import Component Styles
* ============================================================================= */
.rbacExportImport {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.rbacExportImport {
grid-template-columns: 1fr;
}
}
/* =============================================================================
* Section
* ============================================================================= */
.section {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.sectionIcon {
color: var(--primary-color);
font-size: 1.125rem;
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.sectionContent {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.sectionDescription {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.5;
}
/* =============================================================================
* Buttons
* ============================================================================= */
.primaryButton {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.primaryButton:hover:not(:disabled) {
background: var(--primary-color-dark);
}
.primaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.primaryButton.danger {
background: #c53030;
}
.primaryButton.danger:hover:not(:disabled) {
background: #9b2c2c;
}
.clearButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-tertiary);
cursor: pointer;
transition: all 0.2s;
}
.clearButton:hover {
background: #fed7d7;
color: #c53030;
border-color: #fc8181;
}
.previewButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.8125rem;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.previewButton:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.closeButton {
background: none;
border: none;
font-size: 1.25rem;
color: var(--text-tertiary);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.closeButton:hover {
color: var(--text-primary);
}
/* =============================================================================
* File Upload
* ============================================================================= */
.fileUpload {
display: flex;
align-items: center;
gap: 0.5rem;
}
.fileInput {
display: none;
}
.fileLabel {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border: 2px dashed var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
font-size: 0.875rem;
}
.fileLabel:hover {
border-color: var(--primary-color);
background: var(--bg-secondary);
}
.fileIcon {
font-size: 1.25rem;
}
/* =============================================================================
* Import Info & Stats
* ============================================================================= */
.importInfo {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 6px;
}
.importStats {
display: flex;
gap: 1rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
/* =============================================================================
* Import Mode Selection
* ============================================================================= */
.importModeSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.importModeTitle {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
}
.importModes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.importModeOption {
display: grid;
grid-template-columns: auto auto 1fr;
grid-template-rows: auto auto;
gap: 0.25rem 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.importModeOption:hover {
background: var(--bg-secondary);
}
.importModeOption.selected {
border-color: var(--primary-color);
background: var(--primary-color-light);
}
.radioInput {
grid-row: span 2;
align-self: center;
accent-color: var(--primary-color);
}
.modeIcon {
grid-row: span 2;
align-self: center;
font-size: 1.125rem;
}
.modeLabel {
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.modeDescription {
font-size: 0.75rem;
color: var(--text-tertiary);
grid-column: 3;
}
/* =============================================================================
* Messages
* ============================================================================= */
.errorMessage {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #fed7d7;
color: #c53030;
border-radius: 6px;
font-size: 0.875rem;
}
.warningMessage {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: #fefcbf;
color: #744210;
border-radius: 6px;
font-size: 0.8125rem;
line-height: 1.4;
}
/* =============================================================================
* Modal
* ============================================================================= */
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--bg-primary);
border-radius: 8px;
max-width: 500px;
width: 100%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
/* =============================================================================
* Preview
* ============================================================================= */
.preview {
display: flex;
flex-direction: column;
}
.previewHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.previewTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.previewContent {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.previewSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.previewSection h5 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
}
.previewList {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: var(--text-primary);
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.previewList code {
background: var(--bg-secondary);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.75rem;
}
.featureBadge,
.contextBadge {
display: inline-block;
padding: 0.125rem 0.375rem;
background: var(--primary-color-light);
color: var(--primary-color);
font-size: 0.625rem;
font-weight: 600;
border-radius: 3px;
margin-left: 0.5rem;
text-transform: uppercase;
}
.contextBadge {
background: var(--bg-tertiary);
color: var(--text-secondary);
margin-left: 0;
margin-right: 0.5rem;
}
.moreItems {
color: var(--text-tertiary);
font-style: italic;
}
/* =============================================================================
* Import Result
* ============================================================================= */
.importResult {
display: flex;
flex-direction: column;
}
.resultHeader {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.importResult.success .resultHeader {
background: #c6f6d5;
}
.importResult.error .resultHeader {
background: #fed7d7;
}
.resultIcon {
font-size: 1.25rem;
}
.importResult.success .resultIcon {
color: #38a169;
}
.importResult.error .resultIcon {
color: #c53030;
}
.resultTitle {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.resultContent {
padding: 1rem;
}
.resultStats {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
}
.resultErrors {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.resultErrors h5 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
color: #c53030;
}
.resultErrors ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: #c53030;
}
/* =============================================================================
* Spinning Animation
* ============================================================================= */
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View file

@ -1,469 +0,0 @@
/**
* RbacExportImport
*
* Component for exporting and importing RBAC configurations.
* Supports mandate-level and global exports with different import modes.
*/
import React, { useState, useRef } from 'react';
import {
FaDownload,
FaUpload,
FaFileExport,
FaFileImport,
FaSpinner,
FaCheckCircle,
FaExclamationTriangle,
FaInfoCircle,
FaTrash,
FaEye,
} from 'react-icons/fa';
import {
useRbacExportImport,
type RbacExport,
type ImportMode,
type RbacImportResult,
} from '../../hooks/useRbacExportImport';
import styles from './RbacExportImport.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// =============================================================================
// TYPES
// =============================================================================
interface RbacExportImportProps {
mandateId?: string;
mandateName?: string;
isGlobal?: boolean;
featureCode?: string;
}
// =============================================================================
// IMPORT MODE OPTIONS
// =============================================================================
function _getImportModes(t: (key: string) => string): { value: ImportMode; label: string; description: string; icon: React.ReactNode }[] {
return [
{
value: 'merge',
label: t('Zusammenführen'),
description: t('Bestehende Regeln aktualisieren'),
icon: <FaCheckCircle style={{ color: '#38a169' }} />,
},
{
value: 'add_only',
label: t('Nur hinzufügen'),
description: t('Nur neue Regeln hinzufügen'),
icon: <FaInfoCircle style={{ color: '#3182ce' }} />,
},
{
value: 'replace',
label: t('Ersetzen'),
description: t('Alle bestehenden Regeln löschen'),
icon: <FaExclamationTriangle style={{ color: '#d69e2e' }} />,
},
];
}
// =============================================================================
// PREVIEW COMPONENT
// =============================================================================
interface PreviewProps {
data: RbacExport;
onClose: () => void;
}
const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
const { t } = useLanguage();
return (
<div className={styles.preview}>
<div className={styles.previewHeader}>
<h4 className={styles.previewTitle}>{t('Export-Vorschau')}</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.previewContent}>
<div className={styles.previewSection}>
<h5>{t('Scope')}</h5>
<ul className={styles.previewList}>
<li><strong>{t('Typ:')}</strong> {data.scope.type}</li>
{data.scope.mandateName && <li><strong>{t('Mandant')}</strong> {data.scope.mandateName}</li>}
{data.scope.featureCode && <li><strong>{t('Feature:')}</strong> {data.scope.featureCode}</li>}
</ul>
</div>
<div className={styles.previewSection}>
<h5>{t('Rollen ({count})', { count: String(data.roles.length) })}</h5>
<ul className={styles.previewList}>
{data.roles.slice(0, 5).map((role, i) => (
<li key={i}>
<code>{role.roleLabel}</code>
{role.featureCode && <span className={styles.featureBadge}>{role.featureCode}</span>}
</li>
))}
{data.roles.length > 5 && (
<li className={styles.moreItems}>{t('... und {count} weitere', { count: String(data.roles.length - 5) })}</li>
)}
</ul>
</div>
<div className={styles.previewSection}>
<h5>{t('Regeln ({count})', { count: String(data.accessRules.length) })}</h5>
<ul className={styles.previewList}>
{data.accessRules.slice(0, 5).map((rule, i) => (
<li key={i}>
<span className={styles.contextBadge}>{rule.context}</span>
<code>{rule.item || t('(global)')}</code>
</li>
))}
{data.accessRules.length > 5 && (
<li className={styles.moreItems}>{t('... und {count} weitere', { count: String(data.accessRules.length - 5) })}</li>
)}
</ul>
</div>
</div>
</div>
);
};
// =============================================================================
// IMPORT RESULT COMPONENT
// =============================================================================
interface ImportResultProps {
result: RbacImportResult;
onClose: () => void;
}
const ImportResult: React.FC<ImportResultProps> = ({ result, onClose }) => {
const { t } = useLanguage();
const importModes = _getImportModes(t);
const isSuccess = result.status === 'success';
return (
<div className={`${styles.importResult} ${isSuccess ? styles.success : styles.error}`}>
<div className={styles.resultHeader}>
{isSuccess ? (
<FaCheckCircle className={styles.resultIcon} />
) : (
<FaExclamationTriangle className={styles.resultIcon} />
)}
<h4 className={styles.resultTitle}>
{isSuccess ? t('Import erfolgreich') : t('Import fehlgeschlagen')}
</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.resultContent}>
<ul className={styles.resultStats}>
<li><strong>{t('Modus:')}</strong> {importModes.find(m => m.value === result.mode)?.label}</li>
<li><strong>{t('Rollen erstellt')}</strong> {result.rolesCreated}</li>
<li><strong>{t('Rollen aktualisiert')}</strong> {result.rolesUpdated}</li>
<li><strong>{t('Regeln erstellt')}</strong> {result.rulesCreated}</li>
<li><strong>{t('Regeln aktualisiert')}</strong> {result.rulesUpdated}</li>
</ul>
{result.errors && result.errors.length > 0 && (
<div className={styles.resultErrors}>
<h5>{t('Fehler')}</h5>
<ul>
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const RbacExportImport: React.FC<RbacExportImportProps> = ({
mandateId,
mandateName,
isGlobal = false,
featureCode,
}) => {
const { t } = useLanguage();
const importModes = _getImportModes(t);
const {
exporting,
importing,
error,
lastImportResult,
exportMandateRbac,
exportGlobalRbac,
importMandateRbac,
importGlobalRbac,
downloadExport,
parseImportFile,
reset,
} = useRbacExportImport();
const [importMode, setImportMode] = useState<ImportMode>('merge');
const [importFile, setImportFile] = useState<File | null>(null);
const [importData, setImportData] = useState<RbacExport | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showResult, setShowResult] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle export
const handleExport = async () => {
let result;
if (isGlobal) {
result = await exportGlobalRbac(featureCode);
} else if (mandateId) {
result = await exportMandateRbac(mandateId, featureCode);
} else {
return;
}
if (result.success && result.data) {
downloadExport(result.data);
}
};
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImportFile(file);
setParseError(null);
const result = await parseImportFile(file);
if (result.success && result.data) {
setImportData(result.data);
} else {
setParseError(result.error || t('Fehler beim Parsen'));
setImportData(null);
}
};
// Handle import
const handleImport = async () => {
if (!importData) return;
let result;
if (isGlobal) {
result = await importGlobalRbac(importData, importMode);
} else if (mandateId) {
result = await importMandateRbac(mandateId, importData, importMode);
} else {
return;
}
if (result.success) {
setShowResult(true);
// Clear import state
setImportFile(null);
setImportData(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Clear import state
const handleClearImport = () => {
setImportFile(null);
setImportData(null);
setParseError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Handle close result
const handleCloseResult = () => {
setShowResult(false);
reset();
};
return (
<div className={styles.rbacExportImport}>
{/* Export Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileExport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('Export')}</h3>
</div>
<div className={styles.sectionContent}>
<p className={styles.sectionDescription}>
{t('Exportiert alle Rollen und Berechtigungen')}{' '}
{isGlobal ? t('der globalen Templates') : t('des Mandanten "{name}"', { name: String(mandateName || mandateId || '') })}
{featureCode ? <> {t('für Feature "{code}"', { code: featureCode })}</> : null}{' '}
{t('als JSON-Datei.')}
</p>
<button
className={styles.primaryButton}
onClick={handleExport}
disabled={exporting || (!isGlobal && !mandateId)}
>
{exporting ? (
<>
<FaSpinner className="spinning" /> {t('Exportieren...')}
</>
) : (
<>
<FaDownload /> {t('RBAC exportieren')}
</>
)}
</button>
</div>
</div>
{/* Import Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileImport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('Import')}</h3>
</div>
<div className={styles.sectionContent}>
{/* File Upload */}
<div className={styles.fileUpload}>
<input
type="file"
ref={fileInputRef}
accept=".json"
onChange={handleFileSelect}
className={styles.fileInput}
id="rbac-import-file"
/>
<label htmlFor="rbac-import-file" className={styles.fileLabel}>
{importFile ? (
<>
<FaCheckCircle className={styles.fileIcon} style={{ color: '#38a169' }} />
<span>{importFile.name}</span>
</>
) : (
<>
<FaUpload className={styles.fileIcon} />
<span>{t('JSON-Datei auswählen oder hier ablegen')}</span>
</>
)}
</label>
{importFile && (
<button
className={styles.clearButton}
onClick={handleClearImport}
title={t('Datei entfernen')}
>
<FaTrash />
</button>
)}
</div>
{/* Parse Error */}
{parseError && (
<div className={styles.errorMessage}>
<FaExclamationTriangle /> {parseError}
</div>
)}
{/* Import Data Info */}
{importData && (
<div className={styles.importInfo}>
<div className={styles.importStats}>
<span><strong>{t('Rollen')}</strong> {importData.roles.length}</span>
<span><strong>{t('Regeln:')}</strong> {importData.accessRules.length}</span>
<span><strong>{t('Quelle:')}</strong> {importData.scope.type}</span>
</div>
<button
className={styles.previewButton}
onClick={() => setShowPreview(true)}
>
<FaEye /> {t('Vorschau')}
</button>
</div>
)}
{/* Import Mode Selection */}
{importData && (
<div className={styles.importModeSection}>
<h4 className={styles.importModeTitle}>{t('Import-Modus')}</h4>
<div className={styles.importModes}>
{importModes.map(mode => (
<label
key={mode.value}
className={`${styles.importModeOption} ${importMode === mode.value ? styles.selected : ''}`}
>
<input
type="radio"
name="importMode"
value={mode.value}
checked={importMode === mode.value}
onChange={(e) => setImportMode(e.target.value as ImportMode)}
className={styles.radioInput}
/>
<span className={styles.modeIcon}>{mode.icon}</span>
<span className={styles.modeLabel}>{mode.label}</span>
<span className={styles.modeDescription}>{mode.description}</span>
</label>
))}
</div>
</div>
)}
{/* Import Button */}
{importData && (
<button
className={`${styles.primaryButton} ${importMode === 'replace' ? styles.danger : ''}`}
onClick={handleImport}
disabled={importing || (!isGlobal && !mandateId)}
>
{importing ? (
<>
<FaSpinner className="spinning" /> {t('Importieren...')}
</>
) : (
<>
<FaUpload /> {t('RBAC importieren')}
</>
)}
</button>
)}
{/* Warning for replace mode */}
{importMode === 'replace' && importData && (
<div className={styles.warningMessage}>
<FaExclamationTriangle />
<strong>{t('Achtung:')}</strong>{' '}
{t('Im Modus Ersetzen werden alle bestehenden Rollen und Regeln gelöscht!')}
</div>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div className={styles.errorMessage}>
<FaExclamationTriangle /> {error}
</div>
)}
{/* Preview Modal */}
{showPreview && importData && (
<div className={styles.modalOverlay} onClick={() => setShowPreview(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<ExportPreview data={importData} onClose={() => setShowPreview(false)} />
</div>
</div>
)}
{/* Result Modal */}
{showResult && lastImportResult && (
<div className={styles.modalOverlay} onClick={handleCloseResult}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<ImportResult result={lastImportResult} onClose={handleCloseResult} />
</div>
</div>
)}
</div>
);
};
export default RbacExportImport;

View file

@ -1,5 +0,0 @@
/**
* RBAC Export/Import Components
*/
export { RbacExportImport } from './RbacExportImport';

View file

@ -36,6 +36,7 @@ import {
export const PAGE_ICONS: Record<string, React.ReactNode> = {
// System pages
'page.system.home': <FaHome />,
'page.system.integrations': <FaProjectDiagram />,
'page.system.settings': <FaCog />,
'page.system.store': <FaStore />,
'page.system.gdpr': <FaShieldAlt />,

View file

@ -0,0 +1,322 @@
/**
* Aggregates data for the Integrations architecture page.
* Primary payload: GET /api/system/integrations-overview (no fictitious diagram data).
*/
import { useState, useEffect, useCallback } from 'react';
import api from '../api';
import type { FeatureInstance, NavigationMandate } from './useNavigation';
export interface AicoreModuleRow {
connectorType: string;
label: string;
modelCount: number;
}
export interface InfraToolRow {
id: string;
label: string;
}
export type DataLayerItemKind =
| 'userConnection'
| 'dataSource'
| 'featureDataSource'
| 'trusteeAccounting';
export interface DataLayerItem {
kind: DataLayerItemKind;
id: string;
/** userConnection */
displayLabel?: string;
connectionReference?: string;
authority?: string;
/** dataSource */
label?: string;
sourceType?: string;
connectionId?: string;
/** shared */
featureInstanceId?: string | null;
mandateId?: string | null;
/** featureDataSource */
featureCode?: string;
tableName?: string;
/** trusteeAccounting */
instanceLabel?: string;
connectorType?: string;
}
export interface LiveStats {
aiCallCount: number;
aiCallPeriodDays: number;
totalWorkflows: number;
activeWorkflows: number;
totalRuns: number;
totalTokens: number;
}
export interface ExtractorClassRow {
className: string;
extensions: string[];
}
export interface RendererClassRow {
className: string;
formats: string[];
}
export interface IntegrationsDiagramPayload {
aicoreModules: AicoreModuleRow[];
infraTools: InfraToolRow[];
extractorExtensions: string[];
extractorClasses: ExtractorClassRow[];
rendererFormats: string[];
rendererClasses: RendererClassRow[];
dataLayerItems: DataLayerItem[];
liveStats: LiveStats;
errors?: string[];
}
export interface MandateCardData {
id: string;
uiLabel: string;
dotColor: string;
/** "Feature: Instanzbezeichnung" per instance */
moduleChips: string[];
}
export interface UseIntegrationsOverviewResult {
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
diagram: IntegrationsDiagramPayload | null;
mandateCards: MandateCardData[];
workflowChips: string[];
hasNeutralization: boolean;
}
function _dotColorForIndex(index: number): string {
const palette = ['#378ADD', '#1D9E75', '#D85A30', '#8B5CF6', '#EC4899', '#0EA5E9'];
return palette[index % palette.length];
}
function _collectGraphicalEditorInstanceIds(mandates: NavigationMandate[]): string[] {
const ids: string[] = [];
for (const mandate of mandates) {
for (const feature of mandate.features) {
if (feature.uiComponent === 'feature.graphicalEditor') {
for (const inst of feature.instances) {
if (inst.id && !ids.includes(inst.id)) {
ids.push(inst.id);
}
}
}
}
}
return ids;
}
function _hasFeatureCode(mandates: NavigationMandate[], code: string): boolean {
for (const mandate of mandates) {
for (const feature of mandate.features) {
if (feature.uiComponent === `feature.${code}`) {
return true;
}
}
}
return false;
}
function _featureCodeFromUiComponent(uiComponent: string): string {
return uiComponent.startsWith('feature.') ? uiComponent.slice(8) : uiComponent;
}
function _instanceChipLine(inst: FeatureInstance, featureUiComponent: string): string {
const label = (inst.uiLabel || '').trim();
const code = (inst.featureCode || _featureCodeFromUiComponent(featureUiComponent)).trim();
if (label && code) {
return `${label} (${code})`;
}
if (label) {
return label;
}
return code;
}
function _buildMandateCards(mandates: NavigationMandate[]): MandateCardData[] {
return mandates.map((m, i) => {
const moduleChips: string[] = [];
for (const f of m.features) {
for (const inst of f.instances) {
const line = _instanceChipLine(inst, f.uiComponent);
if (line && !moduleChips.includes(line)) {
moduleChips.push(line);
}
}
}
return {
id: m.id,
uiLabel: m.uiLabel,
dotColor: _dotColorForIndex(i),
moduleChips: moduleChips.slice(0, 24),
};
});
}
const _DEFAULT_LIVE_STATS: LiveStats = {
aiCallCount: 0,
aiCallPeriodDays: 30,
totalWorkflows: 0,
activeWorkflows: 0,
totalRuns: 0,
totalTokens: 0,
};
function _normalizeExtractorClasses(raw: unknown): ExtractorClassRow[] {
if (!Array.isArray(raw)) return [];
const out: ExtractorClassRow[] = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const r = row as Record<string, unknown>;
const className = typeof r.className === 'string' ? r.className : '';
const extensions = Array.isArray(r.extensions) ? (r.extensions as string[]) : [];
if (className && extensions.length) out.push({ className, extensions });
}
return out;
}
function _normalizeRendererClasses(raw: unknown): RendererClassRow[] {
if (!Array.isArray(raw)) return [];
const out: RendererClassRow[] = [];
for (const row of raw) {
if (!row || typeof row !== 'object') continue;
const r = row as Record<string, unknown>;
const className = typeof r.className === 'string' ? r.className : '';
const formats = Array.isArray(r.formats) ? (r.formats as string[]) : [];
if (className && formats.length) out.push({ className, formats });
}
return out;
}
function _normalizeDiagramPayload(raw: unknown): IntegrationsDiagramPayload {
const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const rawStats = o.liveStats && typeof o.liveStats === 'object'
? (o.liveStats as Record<string, unknown>)
: {};
return {
aicoreModules: Array.isArray(o.aicoreModules) ? (o.aicoreModules as AicoreModuleRow[]) : [],
infraTools: Array.isArray(o.infraTools) ? (o.infraTools as InfraToolRow[]) : [],
extractorExtensions: Array.isArray(o.extractorExtensions)
? (o.extractorExtensions as string[])
: [],
extractorClasses: _normalizeExtractorClasses(o.extractorClasses),
rendererFormats: Array.isArray(o.rendererFormats) ? (o.rendererFormats as string[]) : [],
rendererClasses: _normalizeRendererClasses(o.rendererClasses),
dataLayerItems: Array.isArray(o.dataLayerItems) ? (o.dataLayerItems as DataLayerItem[]) : [],
liveStats: {
aiCallCount: typeof rawStats.aiCallCount === 'number' ? rawStats.aiCallCount : _DEFAULT_LIVE_STATS.aiCallCount,
aiCallPeriodDays: typeof rawStats.aiCallPeriodDays === 'number' ? rawStats.aiCallPeriodDays : _DEFAULT_LIVE_STATS.aiCallPeriodDays,
totalWorkflows: typeof rawStats.totalWorkflows === 'number' ? rawStats.totalWorkflows : _DEFAULT_LIVE_STATS.totalWorkflows,
activeWorkflows: typeof rawStats.activeWorkflows === 'number' ? rawStats.activeWorkflows : _DEFAULT_LIVE_STATS.activeWorkflows,
totalRuns: typeof rawStats.totalRuns === 'number' ? rawStats.totalRuns : _DEFAULT_LIVE_STATS.totalRuns,
totalTokens: typeof rawStats.totalTokens === 'number' ? rawStats.totalTokens : _DEFAULT_LIVE_STATS.totalTokens,
},
errors: Array.isArray(o.errors) ? (o.errors as string[]) : undefined,
};
}
export function useIntegrationsOverview(): UseIntegrationsOverviewResult {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [diagram, setDiagram] = useState<IntegrationsDiagramPayload | null>(null);
const [mandateCards, setMandateCards] = useState<MandateCardData[]>([]);
const [workflowChips, setWorkflowChips] = useState<string[]>([]);
const [hasNeutralization, setHasNeutralization] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [navResult, diagramResult] = await Promise.allSettled([
api.get('/api/navigation'),
api.get('/api/system/integrations-overview'),
]);
let mandatesForWorkflows: NavigationMandate[] = [];
if (navResult.status === 'fulfilled') {
const blocks = navResult.value.data?.blocks ?? [];
const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
mandatesForWorkflows = dynamicBlock?.mandates ?? [];
setMandateCards(_buildMandateCards(mandatesForWorkflows));
setHasNeutralization(_hasFeatureCode(mandatesForWorkflows, 'neutralization'));
} else {
setMandateCards([]);
setHasNeutralization(false);
setError(
navResult.reason instanceof Error
? navResult.reason.message
: String(navResult.reason),
);
}
if (diagramResult.status === 'fulfilled') {
setDiagram(_normalizeDiagramPayload(diagramResult.value.data));
} else {
setDiagram(_normalizeDiagramPayload({}));
const msg =
diagramResult.reason instanceof Error
? diagramResult.reason.message
: String(diagramResult.reason);
setError((prev) => (prev ? `${prev} | ${msg}` : msg));
}
const geIds = _collectGraphicalEditorInstanceIds(mandatesForWorkflows);
const wfLabels: string[] = [];
const seenWf = new Set<string>();
for (const instanceId of geIds.slice(0, 4)) {
try {
const wfRes = await api.get(`/api/workflows/${instanceId}/workflows`, {
params: { active: 'true' },
});
const wfData = wfRes.data;
const list = Array.isArray(wfData)
? wfData
: (wfData as { items?: { label?: string }[]; workflows?: { label?: string }[] })?.items ??
(wfData as { workflows?: { label?: string }[] })?.workflows ??
[];
for (const w of list) {
const lab = (w as { label?: string }).label;
if (lab && !seenWf.has(lab)) {
seenWf.add(lab);
wfLabels.push(lab);
}
if (wfLabels.length >= 8) break;
}
} catch {
/* ignore */
}
if (wfLabels.length >= 8) break;
}
setWorkflowChips(wfLabels.slice(0, 8));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return {
loading,
error,
refetch: load,
diagram,
mandateCards,
workflowChips,
hasNeutralization,
};
}

View file

@ -55,6 +55,8 @@ export interface FeatureView {
export interface FeatureInstance {
id: string;
uiLabel: string;
/** Feature type code, e.g. trustee, workspace (for display: Label (code)) */
featureCode?: string;
order: number;
views: FeatureView[];
isAdmin?: boolean;

View file

@ -1,270 +0,0 @@
/**
* useRbacExportImport Hook
*
* Hook for exporting and importing RBAC configurations.
* Supports mandate-level and global (template) exports.
*/
import { useState, useCallback } from 'react';
import api from '../api';
// =============================================================================
// TYPES
// =============================================================================
export type ImportMode = 'merge' | 'replace' | 'add_only';
export interface RbacExportScope {
type: 'global' | 'mandate' | 'instance';
mandateId?: string;
mandateName?: string;
featureInstanceId?: string;
featureCode?: string;
instanceLabel?: string;
}
export interface RbacExportRole {
roleLabel: string;
description?: string;
featureCode?: string;
}
export interface RbacExportRule {
roleLabel: string;
context: 'DATA' | 'UI' | 'RESOURCE';
item: string | null;
view: boolean;
read?: string | null;
create?: string | null;
update?: string | null;
delete?: string | null;
}
export interface RbacExport {
version: string;
exportedAt: string;
exportedBy?: string;
scope: RbacExportScope;
roles: RbacExportRole[];
accessRules: RbacExportRule[];
}
export interface RbacImportResult {
status: 'success' | 'error';
mode: ImportMode;
rolesCreated: number;
rolesUpdated: number;
rulesCreated: number;
rulesUpdated: number;
errors?: string[];
}
// =============================================================================
// HOOK
// =============================================================================
export function useRbacExportImport() {
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastExport, setLastExport] = useState<RbacExport | null>(null);
const [lastImportResult, setLastImportResult] = useState<RbacImportResult | null>(null);
/**
* Export RBAC configuration for a mandate
*/
const exportMandateRbac = useCallback(async (
mandateId: string,
featureCode?: string
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
setExporting(true);
setError(null);
try {
const params = new URLSearchParams();
if (featureCode) params.append('featureCode', featureCode);
const url = `/api/mandates/${mandateId}/rbac/export${params.toString() ? '?' + params.toString() : ''}`;
const response = await api.get(url);
setLastExport(response.data);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setExporting(false);
}
}, []);
/**
* Export global RBAC templates (SysAdmin only)
*/
const exportGlobalRbac = useCallback(async (
featureCode?: string
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
setExporting(true);
setError(null);
try {
const params = new URLSearchParams();
if (featureCode) params.append('featureCode', featureCode);
const url = `/api/admin/rbac/global/export${params.toString() ? '?' + params.toString() : ''}`;
const response = await api.get(url);
setLastExport(response.data);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export global RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setExporting(false);
}
}, []);
/**
* Export feature instance RBAC
*/
const exportInstanceRbac = useCallback(async (
instanceId: string
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
setExporting(true);
setError(null);
try {
const response = await api.get(`/api/features/instances/${instanceId}/rbac/export`);
setLastExport(response.data);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export instance RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setExporting(false);
}
}, []);
/**
* Import RBAC configuration into a mandate
*/
const importMandateRbac = useCallback(async (
mandateId: string,
data: RbacExport,
mode: ImportMode = 'merge'
): Promise<{ success: boolean; result?: RbacImportResult; error?: string }> => {
setImporting(true);
setError(null);
try {
const response = await api.post(
`/api/mandates/${mandateId}/rbac/import?mode=${mode}`,
data
);
setLastImportResult(response.data);
return { success: true, result: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to import RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setImporting(false);
}
}, []);
/**
* Import global RBAC templates (SysAdmin only)
*/
const importGlobalRbac = useCallback(async (
data: RbacExport,
mode: ImportMode = 'merge'
): Promise<{ success: boolean; result?: RbacImportResult; error?: string }> => {
setImporting(true);
setError(null);
try {
const response = await api.post(
`/api/admin/rbac/global/import?mode=${mode}`,
data
);
setLastImportResult(response.data);
return { success: true, result: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to import global RBAC';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setImporting(false);
}
}, []);
/**
* Download export as JSON file
*/
const downloadExport = useCallback((data: RbacExport, filename?: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `rbac-export-${data.scope.type}-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
/**
* Parse uploaded JSON file
*/
const parseImportFile = useCallback(async (file: File): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
try {
const text = await file.text();
const data = JSON.parse(text) as RbacExport;
// Basic validation
if (!data.version) {
return { success: false, error: 'Ungültiges Format: Fehlende Version' };
}
if (!data.scope) {
return { success: false, error: 'Ungültiges Format: Fehlender Scope' };
}
if (!Array.isArray(data.roles)) {
return { success: false, error: 'Ungültiges Format: Roles muss ein Array sein' };
}
if (!Array.isArray(data.accessRules)) {
return { success: false, error: 'Ungültiges Format: AccessRules muss ein Array sein' };
}
return { success: true, data };
} catch (err: any) {
return { success: false, error: `Fehler beim Parsen: ${err.message}` };
}
}, []);
/**
* Clear state
*/
const reset = useCallback(() => {
setError(null);
setLastExport(null);
setLastImportResult(null);
}, []);
return {
exporting,
importing,
error,
lastExport,
lastImportResult,
exportMandateRbac,
exportGlobalRbac,
exportInstanceRbac,
importMandateRbac,
importGlobalRbac,
downloadExport,
parseImportFile,
reset,
};
}
export default useRbacExportImport;

View file

@ -1,19 +1,29 @@
/**
* AutomationsDashboardPage
*
* System-level dashboard for workflow runs across all features and mandates.
* Uses /api/system/workflow-runs endpoints with RBAC scoping.
* System-level automation page with two tabs:
* - Dashboard: Metrics + workflow runs table (backend-paginated)
* - Workflows: Central management of all RBAC-accessible workflows across instances
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye } from 'react-icons/fa';
import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
import { Tabs } from '../components/UiComponents/Tabs';
import { useToast } from '../contexts/ToastContext';
import { usePrompt } from '../hooks/usePrompt';
import { useApiRequest } from '../hooks/useApi';
import { formatUnixTimestamp } from '../utils/time';
import { updateWorkflow, executeGraph, deleteWorkflow } from '../api/workflowApi';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
import styles from './admin/Admin.module.css';
// ---------------------------------------------------------------------------
// Shared types & helpers
// ---------------------------------------------------------------------------
interface WorkflowRunMetrics {
totalRuns: number;
runsByStatus: Record<string, number>;
@ -36,6 +46,28 @@ interface WorkflowRun {
sysModifiedAt?: number;
}
interface SystemWorkflow {
id: string;
mandateId: string;
featureInstanceId: string;
label: string;
active: boolean;
isRunning?: boolean;
stuckAtNodeLabel?: string;
stuckAtNodeId?: string;
createdAt?: number;
sysCreatedAt?: number;
lastStartedAt?: number;
runCount?: number;
mandateLabel?: string;
instanceLabel?: string;
canEdit?: boolean;
canDelete?: boolean;
canExecute?: boolean;
invocations?: Array<{ id: string; enabled: boolean; kind: string }>;
graph?: Record<string, any>;
}
function _formatTs(ts?: number): string {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
@ -57,6 +89,10 @@ const _STATUS_COLORS: Record<string, string> = {
cancelled: 'var(--text-secondary, #666)',
};
// ---------------------------------------------------------------------------
// MetricCard
// ---------------------------------------------------------------------------
interface MetricCardProps {
icon: React.ReactNode;
label: string;
@ -88,34 +124,61 @@ const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) =>
</div>
);
export const AutomationsDashboardPage: React.FC = () => {
// ===========================================================================
// DashboardTab — Metrics + Runs table with backend pagination
// ===========================================================================
const _DashboardTab: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [loading, setLoading] = useState(true);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const _load = useCallback(async () => {
const _loadMetrics = useCallback(async () => {
try {
const resp = await api.get('/api/system/workflow-runs/metrics');
setMetrics(resp.data);
} catch (e) {
console.error('[automations] metrics load failed', e);
}
}, []);
const _loadRuns = useCallback(async (paginationParams?: any) => {
setLoading(true);
try {
const [metricsResp, runsResp] = await Promise.all([
api.get('/api/system/workflow-runs/metrics'),
api.get('/api/system/workflow-runs', { params: { limit: 50 } }),
]);
setMetrics(metricsResp.data);
setRuns(runsResp.data?.runs || []);
const params: Record<string, any> = { limit: paginationParams?.pageSize || 25 };
if (paginationParams?.page) {
params.offset = ((paginationParams.page - 1) * (paginationParams.pageSize || 25));
}
if (paginationParams?.search) {
params.search = paginationParams.search;
}
const resp = await api.get('/api/system/workflow-runs', { params });
const data = resp.data;
setRuns(data?.runs || []);
const total = data?.total ?? 0;
const pageSize = params.limit;
setPaginationMeta({
currentPage: paginationParams?.page || 1,
pageSize,
totalItems: total,
totalPages: Math.ceil(total / pageSize),
});
} catch (e) {
console.error('[automations] dashboard load failed', e);
showError(t('Fehler beim Laden des Automations-Dashboards'));
console.error('[automations] runs load failed', e);
showError(t('Fehler beim Laden der Workflow-Runs'));
} finally {
setLoading(false);
}
}, [showError, t]);
useEffect(() => {
_load();
}, [_load]);
_loadMetrics();
_loadRuns();
}, [_loadMetrics, _loadRuns]);
const _downloadRunTracing = useCallback(async (run: WorkflowRun) => {
if (!run.id) return;
@ -212,15 +275,19 @@ export const AutomationsDashboardPage: React.FC = () => {
},
], [t, _downloadRunTracing]);
const _hookData = useMemo(() => ({
refetch: _loadRuns,
pagination: paginationMeta,
}), [_loadRuns, paginationMeta]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Automations')}</h1>
<p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
<button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
@ -279,14 +346,330 @@ export const AutomationsDashboardPage: React.FC = () => {
columns={_runColumns}
loading={loading}
pagination={true}
pageSize={15}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
hookData={_hookData}
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
/>
</div>
</>
);
};
// ===========================================================================
// WorkflowsTab — Central workflow management across all instances
// ===========================================================================
const _WorkflowsTab: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const _load = useCallback(async (paginationParams?: any) => {
setLoading(true);
try {
const params: Record<string, any> = {};
if (activeFilter === 'active') params.active = true;
if (activeFilter === 'inactive') params.active = false;
const pag = {
page: paginationParams?.page || 1,
pageSize: paginationParams?.pageSize || 25,
...(paginationParams?.sort ? { sort: paginationParams.sort } : {}),
...(paginationParams?.search ? { search: paginationParams.search } : {}),
...(paginationParams?.filters ? { filters: paginationParams.filters } : {}),
};
params.pagination = JSON.stringify(pag);
const resp = await api.get('/api/system/workflow-runs/workflows', { params });
const data = resp.data;
setWorkflows(data?.items || []);
setPaginationMeta(data?.pagination || null);
} catch (e) {
console.error('[automations] load system workflows failed', e);
showError(t('Fehler beim Laden der Workflows'));
} finally {
setLoading(false);
}
}, [activeFilter, showError, t]);
useEffect(() => {
_load();
}, [_load]);
const _handleEdit = useCallback((row: SystemWorkflow) => {
if (!row.mandateId || !row.featureInstanceId) return;
navigate(`/mandates/${row.mandateId}/graphicalEditor/${row.featureInstanceId}/editor?workflowId=${row.id}`);
}, [navigate]);
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
const wf = workflows.find(w => w.id === workflowId);
if (!wf?.featureInstanceId) return false;
try {
await deleteWorkflow(request, wf.featureInstanceId, workflowId);
showSuccess(t('Workflow gelöscht'));
await _load();
return true;
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
return false;
}
}, [workflows, request, showSuccess, showError, _load, t]);
const _handleToggleActive = useCallback(async (row: SystemWorkflow) => {
if (!row.featureInstanceId) return;
const next = !(row.active !== false);
setTogglingId(row.id);
try {
await updateWorkflow(request, row.featureInstanceId, row.id, { active: next });
showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert'));
await _load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') }));
} finally {
setTogglingId(null);
}
}, [request, showSuccess, showError, _load, t]);
const _handleRename = useCallback(async (row: SystemWorkflow) => {
if (!row.featureInstanceId) return;
const newLabel = await promptInput(t('Neuer Name:'), {
title: t('Workflow umbenennen'),
defaultValue: row.label,
placeholder: t('Workflow-Name'),
});
if (!newLabel || newLabel.trim() === row.label) return;
try {
await updateWorkflow(request, row.featureInstanceId, row.id, { label: newLabel.trim() });
showSuccess(t('Workflow umbenannt'));
await _load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
}
}, [request, promptInput, showSuccess, showError, _load, t]);
const _handleExecute = useCallback(async (row: SystemWorkflow) => {
if (!row.featureInstanceId || !row.graph) return;
setExecutingId(row.id);
try {
const invs = row.invocations || [];
const primary =
invs.find((i) => i.enabled && i.kind === 'manual') ||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
const result = await executeGraph(request, row.featureInstanceId, row.graph, row.id, {
...(primary ? { entryPointId: primary.id } : {}),
});
if (result?.success) {
showSuccess(result?.paused
? t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.')
: t('Workflow ausgeführt'));
await _load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
} finally {
setExecutingId(null);
}
}, [request, showSuccess, showError, _load, t]);
const _hasManualTrigger = useCallback((row: SystemWorkflow): boolean => {
const invs = row.invocations || [];
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
}, []);
const _columns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
{ key: 'mandateLabel', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true },
{ key: 'instanceLabel', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true },
{
key: 'active',
label: t('Aktiv (Spalte)'),
type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value !== false
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
},
{
key: 'isRunning',
label: t('läuft'),
type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
},
{
key: 'sysCreatedAt',
label: t('Erstellt'),
type: 'number',
width: 140,
sortable: true,
formatter: (v: number) => _formatTs(v),
},
{
key: 'lastStartedAt',
label: t('zuletzt gestartet'),
type: 'number',
width: 160,
formatter: (v: number) => _formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
type: 'number',
width: 80,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
const _hookData = useMemo(() => ({
refetch: _load,
handleDelete: (id: string) => _handleDelete(id),
pagination: paginationMeta,
}), [_load, _handleDelete, paginationMeta]);
return (
<>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>
{t('Alle Workflows über alle Features und Mandanten')}
</p>
</div>
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
{(['all', 'active', 'inactive'] as const).map((f) => (
<button
key={f}
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveFilter(f)}
disabled={loading}
>
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
</button>
))}
</div>
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<SystemWorkflow>
data={workflows}
columns={_columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'edit',
title: t('bearbeiten'),
onAction: _handleEdit,
visible: (row: SystemWorkflow) => row.canEdit === true,
},
{
type: 'delete',
title: t('löschen'),
visible: (row: SystemWorkflow) => row.canDelete === true,
},
]}
customActions={[
{
id: 'view',
icon: <FaEye />,
title: t('anzeigen'),
onClick: (row) => _handleEdit(row),
visible: (row) => row.canEdit !== true,
},
{
id: 'rename',
icon: <FaPen />,
title: t('umbenennen'),
onClick: (row) => _handleRename(row),
visible: (row) => row.canEdit === true,
},
{
id: 'activate',
icon: <FaCheck />,
title: t('aktivieren'),
onClick: (row) => _handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.canEdit === true && row.active === false,
},
{
id: 'deactivate',
icon: <FaBan />,
title: t('deaktivieren'),
onClick: (row) => _handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.canEdit === true && row.active !== false,
},
{
id: 'execute',
icon: <FaPlay />,
title: t('ausführen'),
onClick: (row) => _handleExecute(row),
loading: (row) => executingId === row.id,
visible: (row) => row.canExecute === true && _hasManualTrigger(row),
},
]}
onDelete={(row) => _handleDelete(row.id)}
hookData={_hookData}
emptyMessage={t('Keine Workflows gefunden.')}
/>
</div>
<PromptDialog />
</>
);
};
// ===========================================================================
// Main page with Tabs
// ===========================================================================
export const AutomationsDashboardPage: React.FC = () => {
const { t } = useLanguage();
const tabs = useMemo(() => [
{
id: 'dashboard',
label: t('Dashboard'),
content: <_DashboardTab />,
},
{
id: 'workflows',
label: t('Workflows'),
content: <_WorkflowsTab />,
},
], [t]);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<h1 className={styles.pageTitle}>{t('Automatisierung')}</h1>
<Tabs tabs={tabs} defaultTabId="dashboard" />
</div>
);
};

View file

@ -0,0 +1,984 @@
/*
* IntegrationsOverview PORTA architecture diagram
* Theme vars: --text-primary, --text-secondary, --text-tertiary,
* --bg-primary, --bg-secondary, --surface-color,
* --border-color, --border-dark, --primary-color,
* --object-radius-large (10px), --object-radius-medium (8px),
* --font-family
*/
/* Volle Breite des Content-Bereichs (MainLayout outletShell) — kein künstliches 900px-Cap */
.pageRoot {
width: 100%;
max-width: none;
min-width: 0;
box-sizing: border-box;
padding: 1rem 1.25rem 2rem;
}
.pageIntro {
max-width: 42rem;
}
.diagramScroll {
width: 100%;
max-width: none;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
container-type: inline-size;
container-name: portaDiag;
}
.pageHeading {
font-size: 1.35rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.35rem;
}
.pageLead {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 1rem;
line-height: 1.4;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ── arch wrapper ── */
.arch {
box-sizing: border-box;
font-family: var(--font-family, "DM Sans", sans-serif);
width: 100%;
max-width: none;
min-width: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
padding: 1rem 0 0;
}
/* ── layer labels ── */
.layerLabel {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.layerNum {
font-size: 10px;
font-weight: 700;
background: var(--primary-color, #4A6FA5);
color: #fff;
border-radius: 10px;
padding: 1px 7px;
}
/* ── layers (Schicht 1 + 3) ── */
.layer {
border: 1px solid var(--border-color);
border-radius: var(--object-radius-large, 10px);
padding: 14px 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
}
/* Schicht 3 — Organisation: neutrales Grau */
.layerOrg {
background: #f4f5f7;
border-color: #d8dce3;
}
/* Schicht 1 — Daten: neutrales Grau */
.layerData {
background: #f4f5f7;
border-color: #d8dce3;
}
/* ── vertical arrows ── */
.arrowVert {
display: flex;
justify-content: center;
padding: 4px 0;
}
/* ── Schicht 3: tenants ── */
.tenantGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
gap: 10px;
}
.tenantCard {
background: rgba(74, 111, 165, 0.08);
border: 1px solid rgba(74, 111, 165, 0.25);
border-radius: var(--object-radius-medium, 8px);
padding: 12px 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.tenantEmpty {
grid-column: 1 / -1;
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.tenantName {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 7px;
display: flex;
align-items: center;
gap: 5px;
}
.tenantDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
}
.modGrid {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.modChip {
font-size: 11px;
padding: 3px 8px;
border-radius: 10px;
background: rgba(74, 111, 165, 0.14);
color: #1e3a5f;
font-weight: 500;
white-space: nowrap;
}
/*
Schicht 2: mid-row (Infrastruktur | | PORTA | | Nutzen)
*/
/* Schicht 2 — nur Grid-Layout, kein Hintergrund-Band */
.midRow {
display: grid;
grid-template-columns:
minmax(140px, 1.05fr)
minmax(20px, 32px)
minmax(220px, 2.85fr)
minmax(20px, 32px)
minmax(150px, 1.15fr);
gap: 0;
align-items: stretch;
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 0;
background: transparent;
border: none;
border-radius: 0;
}
:global(.portaArchMidRow) {
display: grid !important;
grid-template-columns:
minmax(140px, 1.05fr)
minmax(20px, 32px)
minmax(220px, 2.85fr)
minmax(20px, 32px)
minmax(150px, 1.15fr) !important;
gap: 0 !important;
align-items: stretch !important;
width: 100%;
min-width: 0 !important;
box-sizing: border-box !important;
padding: 0 !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
}
@container portaDiag (max-width: 480px) {
.midRow,
:global(.portaArchMidRow) {
grid-template-columns: 1fr !important;
}
:global(.portaArchFlowCol) svg {
transform: rotate(90deg);
}
}
/* Viewport-Fallback (ältere Browser / wenn Container nicht greift) */
@media (max-width: 520px) {
.midRow,
:global(.portaArchMidRow) {
grid-template-columns: 1fr !important;
}
:global(.portaArchFlowCol) svg {
transform: rotate(90deg);
}
.tenantGrid {
grid-template-columns: 1fr;
}
}
/* ── Schicht-2 Boxen ── */
.boxInfra {
min-width: 0;
border-radius: var(--object-radius-large, 10px);
padding: 12px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.06),
0 2px 8px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
/* Nutzen: leichtes Violett */
.boxNutzen {
min-width: 0;
border-radius: var(--object-radius-large, 10px);
padding: 12px 14px;
background: rgba(139, 92, 246, 0.06);
border: 1px solid rgba(139, 92, 246, 0.22);
color: var(--text-primary);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.06),
0 2px 8px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
/* PORTA: leichtes Rot */
.boxPorta {
min-width: 0;
border-radius: var(--object-radius-large, 10px);
padding: 12px 14px;
background: rgba(220, 38, 38, 0.05);
border: 1px solid rgba(220, 38, 38, 0.20);
color: var(--text-primary);
box-shadow:
0 2px 5px rgba(0, 0, 0, 0.07),
0 4px 14px rgba(0, 0, 0, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.boxTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.boxTitleIcon {
font-size: 15px;
}
.portaTitleLogo {
width: 62px;
height: 62px;
object-fit: contain;
flex-shrink: 0;
display: block;
}
/* ── Infrastruktur items ── */
.infraBlockTitleWithIcon {
display: flex;
align-items: center;
gap: 5px;
}
.infraTitleSvg {
flex-shrink: 0;
color: var(--primary-color, #4a6fa5);
}
.infraItem {
font-size: 11px;
padding: 4px 7px;
border-radius: var(--object-radius-medium, 8px);
background: var(--bg-primary);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.infraItemGear {
flex-shrink: 0;
color: var(--text-tertiary);
opacity: 0.85;
}
.infraItem:last-child {
margin-bottom: 0;
}
.infraDot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.08);
}
/* Zwei sichtbare Sub-Boxen in Infrastruktur (wie Daten-Schicht) */
.infraSplit {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
}
.infraSubBox {
min-width: 0;
border-radius: var(--object-radius-medium, 8px);
background: rgba(255, 255, 255, 0.50);
border: 1px solid rgba(74, 111, 165, 0.18);
padding: 8px 10px;
}
.infraBlockTitle {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.infraEmptyHint {
font-size: 10px;
color: var(--text-tertiary);
font-style: italic;
line-height: 1.35;
padding: 2px 0;
}
.aicoreGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
gap: 5px;
}
.aicoreModule {
display: flex;
align-items: flex-start;
padding: 5px 6px;
border-radius: var(--object-radius-medium, 8px);
background: var(--bg-primary);
border: 1px solid var(--border-color);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
min-width: 0;
}
.aicoreModuleText {
min-width: 0;
flex: 1;
}
.aicoreModuleTitle {
font-size: 10px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.25;
word-break: break-word;
}
.aicoreModuleMeta {
font-size: 9px;
color: var(--text-tertiary);
margin-top: 2px;
}
.portaEmptyHint {
font-size: 10px;
color: var(--text-tertiary);
margin-bottom: 4px;
line-height: 1.35;
}
/* ── horizontal arrow columns ── */
.flowCol {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
}
:global(.portaArchFlowCol) {
display: flex !important;
align-items: center !important;
justify-content: center !important;
align-self: stretch !important;
}
/* ── PORTA internals ── */
.shieldRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
}
.coreBox {
border: 1px solid rgba(220, 38, 38, 0.25);
border-radius: var(--object-radius-medium, 8px);
padding: 7px 9px;
background: rgba(220, 38, 38, 0.08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 1px 2px rgba(0, 0, 0, 0.04);
}
.coreTitle {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 4px;
}
.coreIcon {
font-size: 12px;
}
.subLabels {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.subLabel {
font-size: 9px;
padding: 1px 5px;
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.secLabel {
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
margin: 6px 0 3px;
}
.wfRow {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
/* Workflow: Kästchen mit Pfeil rechts (dezentes Blau) */
.wfChipFlow {
display: inline-flex;
align-items: stretch;
max-width: 100%;
font-size: 10px;
font-weight: 500;
color: #1e3a5f;
border-radius: 5px;
overflow: hidden;
border: 1px solid rgba(74, 111, 165, 0.35);
background: rgba(74, 111, 165, 0.10);
box-shadow: 0 1px 2px rgba(74, 111, 165, 0.08);
}
.wfChipFlowLabel {
padding: 4px 8px;
min-width: 0;
word-break: break-word;
line-height: 1.25;
}
.wfChipFlowArrow {
display: flex;
align-items: center;
justify-content: center;
padding: 0 6px;
background: rgba(74, 111, 165, 0.16);
border-left: 1px solid rgba(74, 111, 165, 0.30);
color: #4a6fa5;
flex-shrink: 0;
}
/* PORTA: Extractors & Renderers — neutrales Grau */
.portaCodecSplit {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.portaCodecSubBox {
border-radius: var(--object-radius-medium, 8px);
border: 1px solid #d4d8df;
background: #f0f1f4;
padding: 6px 8px;
}
.portaCodecSubTitle {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.35px;
color: var(--text-secondary);
margin-bottom: 5px;
}
.codecSymRow {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.codecSym {
font-size: 10px;
font-weight: 600;
padding: 3px 7px;
border-radius: 5px;
background: #e4e6ea;
border: 1px solid #c4c8d0;
color: #3b4252;
line-height: 1.2;
max-width: 100%;
word-break: break-word;
}
.fileRow {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 2px;
}
.codecList {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 2px;
}
.codecRow {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 8px;
font-size: 10px;
line-height: 1.35;
}
.codecClass {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 9px;
font-weight: 600;
color: var(--text-secondary);
flex: 0 0 auto;
max-width: 100%;
word-break: break-word;
}
.codecBadges {
display: flex;
flex-wrap: wrap;
gap: 2px;
min-width: 0;
flex: 1 1 120px;
}
.fb {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 600;
border: 1px solid transparent;
}
.fbE {
background: rgba(74, 111, 165, 0.12);
color: #3b5e8a;
border-color: rgba(74, 111, 165, 0.28);
}
.fbR {
background: rgba(56, 161, 105, 0.12);
color: #2d6a4f;
border-color: rgba(56, 161, 105, 0.28);
}
:global(.dark-theme) .fbE {
background: rgba(90, 138, 197, 0.18);
color: #a8c4e0;
border-color: rgba(90, 138, 197, 0.32);
}
:global(.dark-theme) .fbR {
background: rgba(72, 187, 120, 0.15);
color: #8ec5a3;
border-color: rgba(72, 187, 120, 0.30);
}
/* ── Nutzen KPI tiles ── */
.statGrid {
display: flex;
flex-direction: column;
gap: 5px;
}
.statTile {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: var(--object-radius-medium, 8px);
background: var(--bg-primary);
border: 1px solid var(--border-color);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
line-height: 1.2;
}
.statValue {
font-size: 1.05rem;
font-weight: 700;
color: #7c3aed;
min-width: 2em;
text-align: right;
flex-shrink: 0;
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
.statText {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.statLabel {
font-size: 11.5px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.statSub {
font-size: 10px;
color: var(--text-tertiary);
}
.statTeaser {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--object-radius-medium, 8px);
border: 1px dashed rgba(139, 92, 246, 0.30);
background: transparent;
line-height: 1.2;
}
.statTeaserPlus {
font-size: 1.1rem;
font-weight: 700;
color: rgba(139, 92, 246, 0.50);
min-width: 1.4em;
text-align: center;
flex-shrink: 0;
}
.statTeaserText {
font-size: 11px;
font-weight: 500;
color: var(--text-tertiary);
font-style: italic;
}
/* ── Schicht 1: data chips ── */
.dataChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
}
.dataLayerSplit {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 12px;
width: 100%;
align-items: start;
}
.dataSubsection {
min-width: 0;
border-radius: var(--object-radius-medium, 8px);
background: rgba(234, 179, 8, 0.08);
border: 1px solid rgba(202, 138, 4, 0.25);
padding: 8px 10px;
}
.dataSubsectionTitle {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.corpInstCard {
background: rgba(234, 179, 8, 0.06);
border: 1px solid rgba(202, 138, 4, 0.22);
border-radius: var(--object-radius-medium, 8px);
padding: 10px 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.dataChipMuted {
font-size: 11px;
color: var(--text-tertiary);
font-style: italic;
padding: 4px 2px;
line-height: 1.35;
}
.dataChip {
font-size: 12px;
padding: 5px 10px;
border-radius: var(--object-radius-medium, 8px);
background: rgba(234, 179, 8, 0.08);
border: 1px solid rgba(202, 138, 4, 0.28);
color: var(--text-primary);
font-weight: 500;
display: flex;
align-items: flex-start;
gap: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
max-width: 100%;
}
.dataChipBody {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.dataChipMain {
font-size: 11px;
font-weight: 600;
line-height: 1.25;
word-break: break-word;
}
.dataChipSub {
font-size: 9px;
font-weight: 400;
color: var(--text-secondary);
line-height: 1.2;
word-break: break-word;
}
.dataIcon {
font-size: 13px;
opacity: 0.8;
flex-shrink: 0;
margin-top: 1px;
}
.sectionDivider {
border: none;
border-top: 1px dashed var(--border-dark, #CBD5E0);
margin: 5px 0;
}
/* ── loading / error ── */
.loadingWrap {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.errorWrap {
padding: 1rem;
color: var(--error-color, #C53030);
}
.errorRetry {
margin-left: 0.35rem;
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
cursor: pointer;
border-radius: var(--object-radius-medium, 8px);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
/* ── dark theme 3D adjustments ── */
:global(.dark-theme) .layer {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
}
:global(.dark-theme) .midRow {
background: transparent !important;
border: none !important;
}
:global(.dark-theme) :global(.portaArchMidRow) {
background: transparent !important;
border: none !important;
}
:global(.dark-theme) .boxInfra {
background: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-primary);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.2),
0 2px 10px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:global(.dark-theme) .boxNutzen {
background: rgba(139, 92, 246, 0.08);
border-color: rgba(139, 92, 246, 0.25);
color: var(--text-primary);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.2),
0 2px 10px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
:global(.dark-theme) .boxPorta {
background: rgba(220, 38, 38, 0.06);
border-color: rgba(220, 38, 38, 0.22);
color: var(--text-primary);
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.25),
0 6px 18px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
:global(.dark-theme) .coreBox {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.28);
}
:global(.dark-theme) .dataSubsection {
background: rgba(234, 179, 8, 0.08);
border-color: rgba(202, 138, 4, 0.28);
}
:global(.dark-theme) .infraSubBox {
background: rgba(0, 0, 0, 0.16);
border-color: rgba(90, 138, 197, 0.32);
}
/* Mandanten: lesbarer Hintergrund im Dunkelmodus */
:global(.dark-theme) .tenantCard {
background: rgba(90, 138, 197, 0.12);
border-color: rgba(90, 138, 197, 0.30);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.22);
}
:global(.dark-theme) .modChip {
background: rgba(90, 138, 197, 0.15);
color: var(--primary-light, #7BA7D7);
}
/* Workflows: dezentes Blau */
:global(.dark-theme) .wfChipFlow {
background: rgba(30, 58, 138, 0.35);
border-color: rgba(147, 197, 253, 0.28);
color: #d0dff6;
}
:global(.dark-theme) .wfChipFlowArrow {
background: rgba(37, 99, 235, 0.28);
border-left-color: rgba(147, 197, 253, 0.22);
color: #b0cbed;
}
/* Extractors/Renderers: neutrales Grau */
:global(.dark-theme) .portaCodecSubBox {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.14);
}
:global(.dark-theme) .codecSym {
background: rgba(255, 255, 255, 0.10);
border-color: rgba(255, 255, 255, 0.18);
color: #c8ccd4;
}
:global(.dark-theme) .infraItem,
:global(.dark-theme) .statTile,
:global(.dark-theme) .dataChip {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
:global(.dark-theme) .layerOrg {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
}
:global(.dark-theme) .layerData {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
}
:global(.dark-theme) .dataChip {
background: rgba(234, 179, 8, 0.10);
border-color: rgba(202, 138, 4, 0.25);
}
:global(.dark-theme) .corpInstCard {
background: rgba(234, 179, 8, 0.07);
border-color: rgba(202, 138, 4, 0.22);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
:global(.dark-theme) .statValue {
color: #a78bfa;
}
:global(.dark-theme) .layerNum {
background: var(--primary-color, #5A8AC5);
}

View file

@ -0,0 +1,490 @@
/**
* PORTA architecture overview data processing organisation.
* Layout matches local/notes/demo-tue-porta_architecture_v3.html (order: Schicht 3 Pfeil Schicht 2 Pfeil Schicht 1).
*/
import React, { useMemo } from 'react';
import { useLanguage } from '../providers/language/LanguageContext';
import { useIntegrationsOverview, type DataLayerItem, type LiveStats } from '../hooks/useIntegrationsOverview';
import styles from './IntegrationsOverview.module.css';
/** de-CH: 1'234'567 */
function _formatStatNumber(n: number): string {
return new Intl.NumberFormat('de-CH', { maximumFractionDigits: 0 }).format(n);
}
function _shortExtractorSymbol(className: string): string {
return className.replace(/Extractor$/i, '') || className;
}
function _shortRendererSymbol(className: string): string {
return className.replace(/^Renderer/i, '') || className;
}
function _IconLightning({ className }: { className?: string }) {
return (
<svg className={className} width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path
fill="currentColor"
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
/>
</svg>
);
}
function _IconGear({ className }: { className?: string }) {
return (
<svg className={className} width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path
fill="currentColor"
d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.48-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
/>
</svg>
);
}
function _ArrowDown() {
return (
<div className={styles.arrowVert} aria-hidden>
<svg width="24" height="28" viewBox="0 0 24 28">
<path
d="M12 2v20M6 16l6 6 6-6"
fill="none"
stroke="var(--text-tertiary, #718096)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function _ArrowUp() {
return (
<div className={styles.arrowVert} aria-hidden>
<svg width="24" height="28" viewBox="0 0 24 28">
<path
d="M12 26V6M6 12l6-6 6 6"
fill="none"
stroke="var(--text-tertiary, #718096)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function _authorityIcon(authority?: string): string {
const a = (authority || '').toLowerCase();
if (a === 'msft') return 'Ⓜ';
if (a === 'google') return 'G';
if (a === 'clickup') return '▣';
if (a === 'local') return '●';
return '◇';
}
function _dataLayerItemKey(item: DataLayerItem): string {
return `${item.kind}-${item.id}`;
}
/** i18n for provider labels where the API sends a fixed German string (e.g. Tavily suffix). */
function _aicoreConnectorLabel(
connectorType: string,
rawLabel: string,
t: (key: string) => string,
): string {
if (connectorType === 'tavily') {
return `Tavily (${t('Websuche')})`;
}
return rawLabel;
}
function _renderPersonalChip(
item: DataLayerItem,
stylesModule: typeof styles,
): React.ReactElement {
return (
<div key={_dataLayerItemKey(item)} className={stylesModule.dataChip}>
<span className={stylesModule.dataIcon}>{_authorityIcon(item.authority)}</span>
<div className={stylesModule.dataChipBody}>
<div className={stylesModule.dataChipMain}>{item.displayLabel}</div>
<div className={stylesModule.dataChipSub}>{item.connectionReference}</div>
</div>
</div>
);
}
interface _CorporateInstanceGroup {
instanceId: string;
instanceLabel: string;
featureCode: string;
systems: { key: string; label: string }[];
}
function _groupCorporateByInstance(items: DataLayerItem[]): _CorporateInstanceGroup[] {
const map = new Map<string, _CorporateInstanceGroup>();
for (const item of items) {
const iid = item.featureInstanceId || '_unknown';
let group = map.get(iid);
if (!group) {
const code = item.featureCode || item.connectorType || '';
const instLabel = item.instanceLabel || code;
group = { instanceId: iid, instanceLabel: instLabel, featureCode: code, systems: [] };
map.set(iid, group);
}
if (item.instanceLabel && !group.instanceLabel) {
group.instanceLabel = item.instanceLabel;
}
const sysLabel = (item.displayLabel || item.label || item.connectorType || item.id).trim();
group.systems.push({ key: `${item.kind}-${item.id}`, label: sysLabel });
}
return Array.from(map.values());
}
function _ArrowRight() {
return (
<div className={`portaArchFlowCol ${styles.flowCol}`} aria-hidden>
<svg width="20" height="14" viewBox="0 0 20 14">
<path
d="M2 7h14M12 3l4 4-4 4"
fill="none"
stroke="var(--text-tertiary, #718096)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
export const IntegrationsOverviewPage: React.FC = () => {
const { t } = useLanguage();
const {
loading,
error,
diagram,
mandateCards,
workflowChips,
hasNeutralization,
refetch,
} = useIntegrationsOverview();
const infraToolRows = useMemo(() => {
const tools = diagram?.infraTools ?? [];
return tools.map((row) => ({ ...row, label: t(row.label) }));
}, [diagram?.infraTools, t]);
const statItems = useMemo(() => {
const s: LiveStats = diagram?.liveStats ?? {
aiCallCount: 0, aiCallPeriodDays: 30,
totalWorkflows: 0, activeWorkflows: 0, totalRuns: 0, totalTokens: 0,
};
const connectedSystems = (diagram?.dataLayerItems ?? [])
.filter((d) => d.kind === 'userConnection').length;
return [
{ value: s.aiCallCount, label: t('AI-Aufrufe'), sub: `${s.aiCallPeriodDays} ${t('Tage')}` },
{ value: s.activeWorkflows, label: t('Aktive Workflows'), sub: s.totalWorkflows > 0 ? `${_formatStatNumber(s.totalWorkflows)} ${t('total')}` : undefined },
{ value: s.totalRuns, label: t('Workflow-Runs'), sub: s.totalTokens > 0 ? `${_formatStatNumber(s.totalTokens)} Tokens` : undefined },
{ value: connectedSystems, label: t('Verbundene Systeme') },
];
}, [diagram, t]);
const dataPersonalItems = useMemo(
() => (diagram?.dataLayerItems ?? []).filter((d) => d.kind === 'userConnection'),
[diagram?.dataLayerItems],
);
const corporateGroups = useMemo(() => {
const items = (diagram?.dataLayerItems ?? []).filter(
(d) => d.kind !== 'userConnection' && d.kind !== 'dataSource',
);
return _groupCorporateByInstance(items);
}, [diagram?.dataLayerItems]);
return (
<div className={styles.pageRoot}>
<div className={styles.pageIntro}>
<h1 className={styles.pageHeading}>{t('Integrationen')}</h1>
<p className={styles.pageLead}>
{t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')}
</p>
</div>
<h2 className={styles.srOnly}>
{t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')}
</h2>
<div className={styles.diagramScroll}>
<div className={styles.arch}>
{loading && <div className={styles.loadingWrap}>{t('Laden…')}</div>}
{error && (
<div className={styles.errorWrap}>
{error}{' '}
<button
type="button"
className={styles.errorRetry}
onClick={() => void refetch()}
>
{t('Erneut versuchen')}
</button>
</div>
)}
{!loading && !error && (
<>
<div className={styles.layerLabel}>
<span className={styles.layerNum}>3</span>
{t('Organisation — Mandanten & Module')}
</div>
<div className={`${styles.layer} ${styles.layerOrg}`}>
<div className={styles.tenantGrid}>
{mandateCards.length === 0 ? (
<p className={styles.tenantEmpty}>
{t('Keine Mandanten in der Navigation sichtbar.')}
</p>
) : (
mandateCards.map((m) => (
<div key={m.id} className={styles.tenantCard}>
<div className={styles.tenantName}>
{m.uiLabel}
</div>
<div className={styles.modGrid}>
{m.moduleChips.map((chip) => (
<span key={chip} className={styles.modChip}>
{chip}
</span>
))}
</div>
</div>
))
)}
</div>
</div>
<_ArrowDown />
<div className={styles.layerLabel}>
<span className={styles.layerNum}>2</span>
{t('Verarbeitung — Infrastruktur → PORTA → Nutzen')}
</div>
<div className={`portaArchMidRow ${styles.midRow}`}>
<div className={styles.boxInfra}>
<div className={styles.boxTitle}>
<span className={styles.boxTitleIcon}></span>
{t('Infrastruktur')}
</div>
<div className={styles.infraSplit}>
<div className={styles.infraSubBox}>
<div className={`${styles.infraBlockTitle} ${styles.infraBlockTitleWithIcon}`}>
<_IconLightning className={styles.infraTitleSvg} />
{t('AI LLM')}
</div>
<div className={styles.aicoreGrid}>
{(diagram?.aicoreModules ?? []).map((m) => (
<div key={m.connectorType} className={styles.aicoreModule}>
<div className={styles.aicoreModuleText}>
<div className={styles.aicoreModuleTitle}>
{_aicoreConnectorLabel(m.connectorType, m.label, t)}
</div>
{m.modelCount > 0 ? (
<div className={styles.aicoreModuleMeta}>
{m.modelCount} {t('Modelle')}
</div>
) : null}
</div>
</div>
))}
</div>
</div>
<div className={styles.infraSubBox}>
<div className={`${styles.infraBlockTitle} ${styles.infraBlockTitleWithIcon}`}>
<_IconGear className={styles.infraTitleSvg} />
{t('Werkzeuge')}
</div>
{infraToolRows.length > 0 ? (
infraToolRows.map((ex) => (
<div key={ex.id} className={styles.infraItem}>
<_IconGear className={styles.infraItemGear} />
{ex.label}
</div>
))
) : (
<div className={styles.infraEmptyHint}>{t('Keine Werkzeuge registriert.')}</div>
)}
</div>
</div>
</div>
<_ArrowRight />
<div className={styles.boxPorta}>
<div className={styles.boxTitle}>
<img
src="/logos/poweron-logo.png"
alt=""
className={styles.portaTitleLogo}
width={62}
height={62}
/>
{t('PORTA')}
</div>
<div className={styles.shieldRow}>
<div className={styles.coreBox}>
<div className={styles.coreTitle}>
<span className={styles.coreIcon}>🛡</span>
{t('Neutralisierung')}
</div>
<div className={styles.subLabels}>
<span className={styles.subLabel}>{t('PII-Masking')}</span>
<span className={styles.subLabel}>{t('Private LLM')}</span>
<span className={styles.subLabel}>{t('Platzhalter')}</span>
</div>
{!hasNeutralization && (
<div className={styles.subLabels}>
<span className={styles.subLabel}>{t('optional pro Instanz')}</span>
</div>
)}
</div>
<div className={styles.coreBox}>
<div className={styles.coreTitle}>
<span className={styles.coreIcon}>🔒</span>
{t('Datenkontrolle')}
</div>
<div className={styles.subLabels}>
<span className={styles.subLabel}>{t('RBAC')}</span>
<span className={styles.subLabel}>{t('Mandanten')}</span>
<span className={styles.subLabel}>{t('Rollen')}</span>
</div>
</div>
</div>
<div className={styles.secLabel}>{t('Workflows')}</div>
{workflowChips.length === 0 ? (
<div className={styles.portaEmptyHint}>{t('Keine Workflows aus Graphical Editor geladen.')}</div>
) : (
<div className={styles.wfRow}>
{workflowChips.map((w) => (
<div key={w} className={styles.wfChipFlow}>
<span className={styles.wfChipFlowLabel}>{w}</span>
<span className={styles.wfChipFlowArrow} aria-hidden>
<svg width="12" height="12" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8z"
/>
</svg>
</span>
</div>
))}
</div>
)}
<div className={styles.portaCodecSplit}>
<div className={styles.portaCodecSubBox}>
<div className={styles.portaCodecSubTitle}>{t('Extractors')}</div>
<div className={styles.codecSymRow}>
{(diagram?.extractorClasses ?? []).length > 0
? (diagram?.extractorClasses ?? []).map((row) => (
<span key={row.className} className={styles.codecSym} title={row.className}>
{_shortExtractorSymbol(row.className)}
</span>
))
: (diagram?.extractorExtensions ?? []).map((b) => (
<span key={b} className={styles.codecSym} title={b}>
{b}
</span>
))}
</div>
</div>
<div className={styles.portaCodecSubBox}>
<div className={styles.portaCodecSubTitle}>{t('Renderers')}</div>
<div className={styles.codecSymRow}>
{(diagram?.rendererClasses ?? []).length > 0
? (diagram?.rendererClasses ?? []).map((row) => (
<span key={row.className} className={styles.codecSym} title={row.className}>
{_shortRendererSymbol(row.className)}
</span>
))
: (diagram?.rendererFormats ?? []).map((b) => (
<span key={b} className={styles.codecSym} title={b}>
{b}
</span>
))}
</div>
</div>
</div>
</div>
<_ArrowRight />
<div className={styles.boxNutzen}>
<div className={styles.boxTitle}>
<span className={styles.boxTitleIcon}></span>
{t('Nutzen')}
</div>
<div className={styles.statGrid}>
{statItems.map((item) => (
<div key={item.label} className={styles.statTile}>
<span className={styles.statValue}>
{typeof item.value === 'number' ? _formatStatNumber(item.value) : item.value}
</span>
<div className={styles.statText}>
<span className={styles.statLabel}>{item.label}</span>
{item.sub ? (
<span className={styles.statSub}>{item.sub}</span>
) : null}
</div>
</div>
))}
<div className={styles.statTeaser}>
<span className={styles.statTeaserPlus}>+</span>
<span className={styles.statTeaserText}>{t('Ihre KPIs — individuell konfigurierbar')}</span>
</div>
</div>
</div>
</div>
<_ArrowUp />
<div className={styles.layerLabel}>
<span className={styles.layerNum}>1</span>
{t('Daten — die Basis von allem')}
</div>
<div className={`${styles.layer} ${styles.layerData}`}>
<div className={styles.dataLayerSplit}>
<div className={styles.dataSubsection}>
<div className={styles.dataSubsectionTitle}>{t('Persönliche Verbindungen')}</div>
{dataPersonalItems.length === 0 ? (
<span className={styles.dataChipMuted}>{t('Keine persönlichen Verbindungen.')}</span>
) : (
<div className={styles.dataChips}>
{dataPersonalItems.map((item) => _renderPersonalChip(item, styles))}
</div>
)}
</div>
<div className={styles.dataSubsection}>
<div className={styles.dataSubsectionTitle}>{t('Unternehmens- & Systemdaten')}</div>
{corporateGroups.length === 0 ? (
<span className={styles.dataChipMuted}>{t('Keine Unternehmens- oder Systemdaten erfasst.')}</span>
) : (
<div className={styles.modGrid}>
{corporateGroups.map((g) => (
<span key={g.instanceId} className={styles.modChip}>
{g.instanceLabel}{g.featureCode ? ` (${g.featureCode})` : ''}
</span>
))}
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
};

View file

@ -18,6 +18,7 @@ import {
saveAccountingConfig,
deleteAccountingConfig,
testAccountingConnection,
exportAccountingData,
type AccountingConnectorInfo,
type AccountingConfig,
} from '../../../api/trusteeApi';
@ -43,6 +44,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
const [importStatus, setImportStatus] = useState<Record<string, any> | null>(null);
const [clearingCache, setClearingCache] = useState(false);
const [exporting, setExporting] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const mountedRef = useRef(true);
@ -429,6 +431,24 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
>
{clearingCache ? t('Leere…') : t('KI-Cache leeren')}
</button>
<button
className={styles.secondaryButton}
disabled={exporting}
onClick={async () => {
if (!instanceId) return;
setExporting(true);
try {
await exportAccountingData(request, instanceId);
showSuccess(t('Export gestartet'), t('Die Daten werden als JSON-Datei heruntergeladen.'));
} catch (err: any) {
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Export fehlgeschlagen.'));
} finally {
setExporting(false);
}
}}
>
{exporting ? t('Exportiere…') : t('Alle Daten exportieren (JSON)')}
</button>
</div>
{importResult && !importResult.errors?.length && (

View file

@ -235,7 +235,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
fileIds,
dataSourceIds,
featureDataSourceIds,
userLanguage: navigator.language?.slice(0, 2) || 'en',
};
if (workflowId) {
body.workflowId = workflowId;