=> {
+ 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
+ ? {t('Ja')}
+ : {t('Nein')},
+ },
+ {
+ key: 'isRunning',
+ label: t('läuft'),
+ type: 'boolean',
+ width: 80,
+ formatter: (value: boolean) =>
+ value
+ ? {t('Ja')}
+ : {t('Nein')},
+ },
+ {
+ 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 (
+ <>
+
+
+
+ {t('Alle Workflows über alle Features und Mandanten')}
+
+
+
+
+ {(['all', 'active', 'inactive'] as const).map((f) => (
+
+ ))}
+
+
+
+
+
+
+
+ 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: ,
+ title: t('anzeigen'),
+ onClick: (row) => _handleEdit(row),
+ visible: (row) => row.canEdit !== true,
+ },
+ {
+ id: 'rename',
+ icon: ,
+ title: t('umbenennen'),
+ onClick: (row) => _handleRename(row),
+ visible: (row) => row.canEdit === true,
+ },
+ {
+ id: 'activate',
+ icon: ,
+ title: t('aktivieren'),
+ onClick: (row) => _handleToggleActive(row),
+ loading: (row) => togglingId === row.id,
+ visible: (row) => row.canEdit === true && row.active === false,
+ },
+ {
+ id: 'deactivate',
+ icon: ,
+ title: t('deaktivieren'),
+ onClick: (row) => _handleToggleActive(row),
+ loading: (row) => togglingId === row.id,
+ visible: (row) => row.canEdit === true && row.active !== false,
+ },
+ {
+ id: 'execute',
+ icon: ,
+ 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.')}
+ />
+
+
+ >
+ );
+};
+
+// ===========================================================================
+// 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 (
+
+
{t('Automatisierung')}
+
);
};
diff --git a/src/pages/IntegrationsOverview.module.css b/src/pages/IntegrationsOverview.module.css
new file mode 100644
index 0000000..1d02aad
--- /dev/null
+++ b/src/pages/IntegrationsOverview.module.css
@@ -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);
+}
diff --git a/src/pages/IntegrationsOverviewPage.tsx b/src/pages/IntegrationsOverviewPage.tsx
new file mode 100644
index 0000000..c97c875
--- /dev/null
+++ b/src/pages/IntegrationsOverviewPage.tsx
@@ -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 (
+
+ );
+}
+
+function _IconGear({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function _ArrowDown() {
+ return (
+
+ );
+}
+
+function _ArrowUp() {
+ return (
+
+ );
+}
+
+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 (
+
+
{_authorityIcon(item.authority)}
+
+
{item.displayLabel}
+
{item.connectionReference}
+
+
+ );
+}
+
+interface _CorporateInstanceGroup {
+ instanceId: string;
+ instanceLabel: string;
+ featureCode: string;
+ systems: { key: string; label: string }[];
+}
+
+function _groupCorporateByInstance(items: DataLayerItem[]): _CorporateInstanceGroup[] {
+ const map = new Map();
+ 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 (
+
+ );
+}
+
+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 (
+
+
+
{t('Integrationen')}
+
+ {t('PORTA Architektur — Daten, Verarbeitung und Mandanten auf einen Blick.')}
+
+
+
+
+ {t('PORTA Architektur v3: Drei separate Boxen in Schicht 2 — Infrastruktur, PORTA, Nutzen')}
+
+
+
+
+ {loading &&
{t('Laden…')}
}
+ {error && (
+
+ {error}{' '}
+
+
+ )}
+
+ {!loading && !error && (
+ <>
+
+ 3
+ {t('Organisation — Mandanten & Module')}
+
+
+
+ {mandateCards.length === 0 ? (
+
+ {t('Keine Mandanten in der Navigation sichtbar.')}
+
+ ) : (
+ mandateCards.map((m) => (
+
+
+ {m.uiLabel}
+
+
+ {m.moduleChips.map((chip) => (
+
+ {chip}
+
+ ))}
+
+
+ ))
+ )}
+
+
+
+ <_ArrowDown />
+
+
+ 2
+ {t('Verarbeitung — Infrastruktur → PORTA → Nutzen')}
+
+
+
+
+ ◧
+ {t('Infrastruktur')}
+
+
+
+
+ <_IconLightning className={styles.infraTitleSvg} />
+ {t('AI LLM')}
+
+
+ {(diagram?.aicoreModules ?? []).map((m) => (
+
+
+
+ {_aicoreConnectorLabel(m.connectorType, m.label, t)}
+
+ {m.modelCount > 0 ? (
+
+ {m.modelCount} {t('Modelle')}
+
+ ) : null}
+
+
+ ))}
+
+
+
+
+ <_IconGear className={styles.infraTitleSvg} />
+ {t('Werkzeuge')}
+
+ {infraToolRows.length > 0 ? (
+ infraToolRows.map((ex) => (
+
+ <_IconGear className={styles.infraItemGear} />
+ {ex.label}
+
+ ))
+ ) : (
+
{t('Keine Werkzeuge registriert.')}
+ )}
+
+
+
+
+ <_ArrowRight />
+
+
+
+

+ {t('PORTA')}
+
+
+
+
+ 🛡
+ {t('Neutralisierung')}
+
+
+ {t('PII-Masking')}
+ {t('Private LLM')}
+ {t('Platzhalter')}
+
+ {!hasNeutralization && (
+
+ {t('optional pro Instanz')}
+
+ )}
+
+
+
+ 🔒
+ {t('Datenkontrolle')}
+
+
+ {t('RBAC')}
+ {t('Mandanten')}
+ {t('Rollen')}
+
+
+
+
{t('Workflows')}
+ {workflowChips.length === 0 ? (
+
{t('Keine Workflows aus Graphical Editor geladen.')}
+ ) : (
+
+ {workflowChips.map((w) => (
+
+ ))}
+
+ )}
+
+
+
{t('Extractors')}
+
+ {(diagram?.extractorClasses ?? []).length > 0
+ ? (diagram?.extractorClasses ?? []).map((row) => (
+
+ {_shortExtractorSymbol(row.className)}
+
+ ))
+ : (diagram?.extractorExtensions ?? []).map((b) => (
+
+ {b}
+
+ ))}
+
+
+
+
{t('Renderers')}
+
+ {(diagram?.rendererClasses ?? []).length > 0
+ ? (diagram?.rendererClasses ?? []).map((row) => (
+
+ {_shortRendererSymbol(row.className)}
+
+ ))
+ : (diagram?.rendererFormats ?? []).map((b) => (
+
+ {b}
+
+ ))}
+
+
+
+
+
+ <_ArrowRight />
+
+
+
+ ✦
+ {t('Nutzen')}
+
+
+ {statItems.map((item) => (
+
+
+ {typeof item.value === 'number' ? _formatStatNumber(item.value) : item.value}
+
+
+ {item.label}
+ {item.sub ? (
+ {item.sub}
+ ) : null}
+
+
+ ))}
+
+ +
+ {t('Ihre KPIs — individuell konfigurierbar')}
+
+
+
+
+
+ <_ArrowUp />
+
+
+ 1
+ {t('Daten — die Basis von allem')}
+
+
+
+
+
{t('Persönliche Verbindungen')}
+ {dataPersonalItems.length === 0 ? (
+
{t('Keine persönlichen Verbindungen.')}
+ ) : (
+
+ {dataPersonalItems.map((item) => _renderPersonalChip(item, styles))}
+
+ )}
+
+
+
{t('Unternehmens- & Systemdaten')}
+ {corporateGroups.length === 0 ? (
+
{t('Keine Unternehmens- oder Systemdaten erfasst.')}
+ ) : (
+
+ {corporateGroups.map((g) => (
+
+ {g.instanceLabel}{g.featureCode ? ` (${g.featureCode})` : ''}
+
+ ))}
+
+ )}
+
+
+
+ >
+ )}
+
+
+
+ );
+};
diff --git a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
index 7fd453c..b3d2b1b 100644
--- a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
+++ b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
@@ -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 | null>(null);
const [importStatus, setImportStatus] = useState | 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')}
+