diff --git a/src/App.tsx b/src/App.tsx index 016a8fe..9ad5e01 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) */} } /> + } /> } /> } /> diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index f467f87..6a3fae0 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -107,10 +107,10 @@ export interface TrusteePosition { export interface AccountingConnectorInfo { connectorType: string; - label: Record; + label: string; configFields: Array<{ key: string; - label: Record; + 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 { + 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); +} diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx index 3cf43e7..2a48ac7 100644 --- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -321,12 +321,12 @@ export function FormGeneratorForm>({ 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) }; }); } diff --git a/src/components/RbacExportImport/RbacExportImport.module.css b/src/components/RbacExportImport/RbacExportImport.module.css deleted file mode 100644 index b045db2..0000000 --- a/src/components/RbacExportImport/RbacExportImport.module.css +++ /dev/null @@ -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); } -} diff --git a/src/components/RbacExportImport/RbacExportImport.tsx b/src/components/RbacExportImport/RbacExportImport.tsx deleted file mode 100644 index 6e1e499..0000000 --- a/src/components/RbacExportImport/RbacExportImport.tsx +++ /dev/null @@ -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: , - }, - { - value: 'add_only', - label: t('Nur hinzufügen'), - description: t('Nur neue Regeln hinzufügen'), - icon: , - }, - { - value: 'replace', - label: t('Ersetzen'), - description: t('Alle bestehenden Regeln löschen'), - icon: , - }, - ]; -} - -// ============================================================================= -// PREVIEW COMPONENT -// ============================================================================= - -interface PreviewProps { - data: RbacExport; - onClose: () => void; -} - -const ExportPreview: React.FC = ({ data, onClose }) => { - const { t } = useLanguage(); - return ( -
-
-

{t('Export-Vorschau')}

- -
-
-
-
{t('Scope')}
-
    -
  • {t('Typ:')} {data.scope.type}
  • - {data.scope.mandateName &&
  • {t('Mandant')} {data.scope.mandateName}
  • } - {data.scope.featureCode &&
  • {t('Feature:')} {data.scope.featureCode}
  • } -
-
-
-
{t('Rollen ({count})', { count: String(data.roles.length) })}
-
    - {data.roles.slice(0, 5).map((role, i) => ( -
  • - {role.roleLabel} - {role.featureCode && {role.featureCode}} -
  • - ))} - {data.roles.length > 5 && ( -
  • {t('... und {count} weitere', { count: String(data.roles.length - 5) })}
  • - )} -
-
-
-
{t('Regeln ({count})', { count: String(data.accessRules.length) })}
-
    - {data.accessRules.slice(0, 5).map((rule, i) => ( -
  • - {rule.context} - {rule.item || t('(global)')} -
  • - ))} - {data.accessRules.length > 5 && ( -
  • {t('... und {count} weitere', { count: String(data.accessRules.length - 5) })}
  • - )} -
-
-
-
- ); -}; - -// ============================================================================= -// IMPORT RESULT COMPONENT -// ============================================================================= - -interface ImportResultProps { - result: RbacImportResult; - onClose: () => void; -} - -const ImportResult: React.FC = ({ result, onClose }) => { - const { t } = useLanguage(); - const importModes = _getImportModes(t); - const isSuccess = result.status === 'success'; - - return ( -
-
- {isSuccess ? ( - - ) : ( - - )} -

- {isSuccess ? t('Import erfolgreich') : t('Import fehlgeschlagen')} -

- -
-
-
    -
  • {t('Modus:')} {importModes.find(m => m.value === result.mode)?.label}
  • -
  • {t('Rollen erstellt')} {result.rolesCreated}
  • -
  • {t('Rollen aktualisiert')} {result.rolesUpdated}
  • -
  • {t('Regeln erstellt')} {result.rulesCreated}
  • -
  • {t('Regeln aktualisiert')} {result.rulesUpdated}
  • -
- {result.errors && result.errors.length > 0 && ( -
-
{t('Fehler')}
-
    - {result.errors.map((err, i) => ( -
  • {err}
  • - ))} -
-
- )} -
-
- ); -}; - -// ============================================================================= -// MAIN COMPONENT -// ============================================================================= - -export const RbacExportImport: React.FC = ({ - 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('merge'); - const [importFile, setImportFile] = useState(null); - const [importData, setImportData] = useState(null); - const [parseError, setParseError] = useState(null); - const [showPreview, setShowPreview] = useState(false); - const [showResult, setShowResult] = useState(false); - - const fileInputRef = useRef(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) => { - 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 ( -
- {/* Export Section */} -
-
- -

{t('Export')}

-
-
-

- {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.')} -

- -
-
- - {/* Import Section */} -
-
- -

{t('Import')}

-
-
- {/* File Upload */} -
- - - {importFile && ( - - )} -
- - {/* Parse Error */} - {parseError && ( -
- {parseError} -
- )} - - {/* Import Data Info */} - {importData && ( -
-
- {t('Rollen')} {importData.roles.length} - {t('Regeln:')} {importData.accessRules.length} - {t('Quelle:')} {importData.scope.type} -
- -
- )} - - {/* Import Mode Selection */} - {importData && ( -
-

{t('Import-Modus')}

-
- {importModes.map(mode => ( - - ))} -
-
- )} - - {/* Import Button */} - {importData && ( - - )} - - {/* Warning for replace mode */} - {importMode === 'replace' && importData && ( -
- - {t('Achtung:')}{' '} - {t('Im Modus Ersetzen werden alle bestehenden Rollen und Regeln gelöscht!')} -
- )} -
-
- - {/* Error Message */} - {error && ( -
- {error} -
- )} - - {/* Preview Modal */} - {showPreview && importData && ( -
setShowPreview(false)}> -
e.stopPropagation()}> - setShowPreview(false)} /> -
-
- )} - - {/* Result Modal */} - {showResult && lastImportResult && ( -
-
e.stopPropagation()}> - -
-
- )} -
- ); -}; - -export default RbacExportImport; diff --git a/src/components/RbacExportImport/index.ts b/src/components/RbacExportImport/index.ts deleted file mode 100644 index 7e63723..0000000 --- a/src/components/RbacExportImport/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * RBAC Export/Import Components - */ - -export { RbacExportImport } from './RbacExportImport'; diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 3690b7c..6bcd858 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -36,6 +36,7 @@ import { export const PAGE_ICONS: Record = { // System pages 'page.system.home': , + 'page.system.integrations': , 'page.system.settings': , 'page.system.store': , 'page.system.gdpr': , diff --git a/src/hooks/useIntegrationsOverview.ts b/src/hooks/useIntegrationsOverview.ts new file mode 100644 index 0000000..0e07a22 --- /dev/null +++ b/src/hooks/useIntegrationsOverview.ts @@ -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; + 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; + 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; + 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) : {}; + const rawStats = o.liveStats && typeof o.liveStats === 'object' + ? (o.liveStats as Record) + : {}; + 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(null); + const [diagram, setDiagram] = useState(null); + const [mandateCards, setMandateCards] = useState([]); + const [workflowChips, setWorkflowChips] = useState([]); + 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(); + 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, + }; +} diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index d8691bb..9067f00 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -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; diff --git a/src/hooks/useRbacExportImport.ts b/src/hooks/useRbacExportImport.ts deleted file mode 100644 index 857916e..0000000 --- a/src/hooks/useRbacExportImport.ts +++ /dev/null @@ -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(null); - const [lastExport, setLastExport] = useState(null); - const [lastImportResult, setLastImportResult] = useState(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; diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index 0494122..26e4539 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -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; @@ -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; +} + 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 = { cancelled: 'var(--text-secondary, #666)', }; +// --------------------------------------------------------------------------- +// MetricCard +// --------------------------------------------------------------------------- + interface MetricCardProps { icon: React.ReactNode; label: string; @@ -88,34 +124,61 @@ const MetricCard: React.FC = ({ icon, label, value, color }) => ); -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(null); const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(true); + const [paginationMeta, setPaginationMeta] = useState(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 = { 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 ( -
+ <>
-

{t('Automations')}

{t('Workflow-Runs über alle Features und Mandanten')}

-
@@ -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.')} />
+ + ); +}; + +// =========================================================================== +// 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([]); + const [loading, setLoading] = useState(true); + const [executingId, setExecutingId] = useState(null); + const [togglingId, setTogglingId] = useState(null); + const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); + const [paginationMeta, setPaginationMeta] = useState(null); + + const _load = useCallback(async (paginationParams?: any) => { + setLoading(true); + try { + const params: Record = {}; + 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 => { + 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) => ( +
+ {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')} +
{importResult && !importResult.errors?.length && ( diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index 0fcff28..21884a8 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -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;