integration view
This commit is contained in:
parent
4762818d3d
commit
88b581ac83
15 changed files with 2241 additions and 1275 deletions
|
|
@ -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 />} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
/**
|
||||
* RBAC Export/Import Components
|
||||
*/
|
||||
|
||||
export { RbacExportImport } from './RbacExportImport';
|
||||
|
|
@ -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 />,
|
||||
|
|
|
|||
322
src/hooks/useIntegrationsOverview.ts
Normal file
322
src/hooks/useIntegrationsOverview.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
984
src/pages/IntegrationsOverview.module.css
Normal file
984
src/pages/IntegrationsOverview.module.css
Normal 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);
|
||||
}
|
||||
490
src/pages/IntegrationsOverviewPage.tsx
Normal file
490
src/pages/IntegrationsOverviewPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue