From 317e019b180c1d0d09df9baeb07b4e870def7715 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 29 Mar 2026 21:55:13 +0200 Subject: [PATCH] unified failsafe neutralization architecture --- src/api/billingApi.ts | 2 +- .../Automation2FlowEditor.tsx | 10 +- .../FolderTree/FolderTree.module.css | 13 +- src/components/FolderTree/FolderTree.tsx | 135 ++++++----- src/pages/Settings.tsx | 6 +- src/pages/basedata/FilesPage.tsx | 7 +- src/pages/billing/BillingDataView.tsx | 217 ++++++++++++++++-- .../views/workspace/NeutralizationPanel.tsx | 5 +- 8 files changed, 305 insertions(+), 90 deletions(-) diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 326fc25..76f79fa 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -5,7 +5,7 @@ import { ApiRequestOptions } from '../hooks/useApi'; // ============================================================================ export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; -export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE'; +export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION'; export interface BillingBalance { mandateId: string; diff --git a/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx index eef7a76..0f93e66 100644 --- a/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx +++ b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx @@ -27,6 +27,7 @@ import { NodeSidebar } from './NodeSidebar'; import { CanvasHeader } from './CanvasHeader'; import { getCategoryIcon } from './utils'; import { fromApiGraph, toApiGraph } from './graphUtils'; +import { usePrompt } from '../../hooks/usePrompt'; import styles from './Automation2FlowEditor.module.css'; const LOG = '[Automation2]'; @@ -44,6 +45,7 @@ export const Automation2FlowEditor: React.FC = ({ initialWorkflowId, }) => { const { request } = useApiRequest(); + const { prompt: promptInput, PromptDialog } = usePrompt(); const [nodeTypes, setNodeTypes] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); @@ -115,8 +117,9 @@ export const Automation2FlowEditor: React.FC = ({ await updateWorkflow(request, instanceId, currentWorkflowId, { graph }); setExecuteResult({ success: true } as ExecuteGraphResponse); } else { - const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow'; - const created = await createWorkflow(request, instanceId, { label, graph }); + const label = await promptInput('Workflow-Name:', { title: 'Workflow speichern', defaultValue: 'Neuer Workflow', placeholder: 'Name des Workflows' }); + if (!label) { setSaving(false); return; } + const created = await createWorkflow(request, instanceId, { label: label.trim() || 'Neuer Workflow', graph }); setCurrentWorkflowId(created.id); setWorkflows((prev) => [...prev, created]); setExecuteResult({ success: true } as ExecuteGraphResponse); @@ -126,7 +129,7 @@ export const Automation2FlowEditor: React.FC = ({ } finally { setSaving(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput]); const handleLoad = useCallback( async (workflowId: string) => { @@ -321,6 +324,7 @@ export const Automation2FlowEditor: React.FC = ({ )} + ); }; diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index 5fd26fa..deab4d3 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -152,10 +152,21 @@ display: flex; gap: 2px; flex-shrink: 0; - margin-left: auto; align-items: center; } +.rightZone { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + flex-shrink: 0; +} + +.rightZone .actions { + margin-left: 0; +} + .rootActions { display: flex; gap: 2px; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 4332748..d4f92ef 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -13,6 +13,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa'; +import { usePrompt } from '../../hooks/usePrompt'; import styles from './FolderTree.module.css'; /* ── Public types ──────────────────────────────────────────────────────── */ @@ -249,68 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { ) : ( {file.fileName} )} - {!renaming && file.fileSize != null && ( - - {(file.fileSize / 1024).toFixed(0)}K - - )} - {!renaming && file.scope != null && ( - - - - - )} {!renaming && ( - - {sel.onRenameFile && !multiSelected && ( - - )} - {multiSelected && isSelected ? ( - <> - {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( - - )} - {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( - - )} - - ) : ( - (sel.onDeleteFile || sel.onDeleteFiles) && ( - - ) + )} + {multiSelected && isSelected ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : ( + (sel.onDeleteFile || sel.onDeleteFiles) && ( + + ) + )} + + {file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} + {file.scope != null && ( + + + + )} )} @@ -328,6 +331,7 @@ interface TreeNodeProps { showFiles: boolean; filesByFolder: Map; sel: SelectionCtx; + promptFolderName: (message: string) => Promise; onToggle: (id: string) => void; onSelect: (id: string | null) => void; onCreateFolder?: (name: string, parentId: string | null) => Promise; @@ -342,6 +346,7 @@ interface TreeNodeProps { function _TreeNode({ node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, + promptFolderName, onToggle, onSelect, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onDownloadFolder, @@ -372,12 +377,12 @@ function _TreeNode({ const _handleAdd = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onCreateFolder) return; - const name = prompt('Neuer Ordnername:'); + const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) { await onCreateFolder(name.trim(), node.id); if (!expandedIds.has(node.id)) onToggle(node.id); } - }, [onCreateFolder, node.id, expandedIds, onToggle]); + }, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]); const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); @@ -539,6 +544,7 @@ function _TreeNode({ showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} + promptFolderName={promptFolderName} onToggle={onToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} @@ -574,6 +580,7 @@ export default function FolderTree({ const [rootDropOver, setRootDropOver] = useState(false); const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); const lastClickedIdRef = useRef(null); + const { prompt: promptFolderName, PromptDialog } = usePrompt(); const expandedIds = externalExpandedIds ?? internalExpandedIds; @@ -753,7 +760,7 @@ export default function FolderTree({ className={styles.actionBtn} onClick={async (e) => { e.stopPropagation(); - const name = prompt('Neuer Ordnername:'); + const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) await onCreateFolder(name.trim(), null); }} title="Neuer Ordner" @@ -774,6 +781,7 @@ export default function FolderTree({ showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} + promptFolderName={promptFolderName} onToggle={_handleToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} @@ -790,6 +798,7 @@ export default function FolderTree({ <_FileItem key={file.id} file={file} sel={sel} /> ))} + ); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ff8af52..84691a2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -542,11 +542,11 @@ export const SettingsPage: React.FC = () => {

Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export, - Loeschung) finden Sie unter GDPR. + Löschung) finden Sie unter GDPR.

-

Datenexport, Portabilitaet und Kontoloeschung.

-
GDPR oeffnen
+

Datenexport, Portabilität und Kontolöschung.

+
GDPR öffnen
)} diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index baa530e..fe984e9 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -16,6 +16,7 @@ import type { FileNode } from '../../components/FolderTree/FolderTree'; import { useResizablePanels } from '../../hooks/useResizablePanels'; import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; +import { usePrompt } from '../../hooks/usePrompt'; import styles from '../admin/Admin.module.css'; interface UserFile { @@ -31,6 +32,7 @@ interface UserFile { export const FilesPage: React.FC = () => { const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); + const { prompt: promptInput, PromptDialog } = usePrompt(); const [selectedFolderId, setSelectedFolderId] = useState(null); const { @@ -223,11 +225,11 @@ export const FilesPage: React.FC = () => { }; const _handleNewFolder = useCallback(async () => { - const name = prompt('Neuer Ordnername:'); + const name = await promptInput('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) { await handleCreateFolder(name.trim(), selectedFolderId); } - }, [handleCreateFolder, selectedFolderId]); + }, [handleCreateFolder, selectedFolderId, promptInput]); const _onRowDragStart = useCallback((e: React.DragEvent, row: UserFile) => { const isInSelection = selectedFiles.some(f => f.id === row.id); @@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => { )} + ); }; diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 4cd1bb3..65501a0 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -15,6 +15,7 @@ import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../ import api from '../../api'; import { useBilling, type BillingBalance } from '../../hooks/useBilling'; import { UserTransaction } from '../../api/billingApi'; +import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import styles from './Billing.module.css'; // ============================================================================ @@ -42,6 +43,17 @@ interface ViewStatistics { timeSeries: Array<{ date: string; cost: number; count: number }>; } +interface DataVolumeInfo { + mandateId: string; + mandateName: string; + usedMB: number; + filesMB: number; + ragIndexMB: number; + maxDataVolumeMB: number | null; + percentUsed: number | null; + warning: boolean; +} + // ============================================================================ // BALANCE CARD COMPONENT // ============================================================================ @@ -364,7 +376,11 @@ export const BillingDataView: React.FC = () => { // Statistics state (shared by Overview and Statistics tabs) const [viewStats, setViewStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); - + + // Storage volume state (for Statistics tab) + const [storageData, setStorageData] = useState([]); + const [storageLoading, setStorageLoading] = useState(false); + // Transactions state (for Transactions tab) const [transactions, setTransactions] = useState([]); const [transactionsLoading, setTransactionsLoading] = useState(false); @@ -406,12 +422,47 @@ export const BillingDataView: React.FC = () => { _loadViewStatistics(period, year, month); }, [_loadViewStatistics]); - // Initial data load: load statistics when overview or statistics tab becomes active + // Load storage volume for all accessible mandates + const _loadStorageData = useCallback(async () => { + const mandateIds = new Set(); + for (const b of balances) { + if (selectedScope === 'personal' || selectedScope === 'all' || selectedScope === b.mandateId) { + mandateIds.add(b.mandateId); + } + } + if (mandateIds.size === 0) { + setStorageData([]); + return; + } + + setStorageLoading(true); + try { + const mandateNameMap = new Map(balances.map(b => [b.mandateId, b.mandateName])); + const results = await Promise.all( + Array.from(mandateIds).map(async (mid) => { + try { + const resp = await api.get(`/api/subscription/data-volume/${mid}`); + return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo; + } catch { + return null; + } + }) + ); + setStorageData(results.filter((r): r is DataVolumeInfo => r !== null)); + } catch { + setStorageData([]); + } finally { + setStorageLoading(false); + } + }, [balances, selectedScope]); + + // Initial data load useEffect(() => { if (activeTab === 'overview' || activeTab === 'statistics') { _loadViewStatistics('month', new Date().getFullYear()); + _loadStorageData(); } - }, [activeTab, _loadViewStatistics, selectedScope]); + }, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]); // Load transactions with pagination support const _loadTransactions = useCallback(async (paginationParams?: any) => { @@ -584,6 +635,56 @@ export const BillingDataView: React.FC = () => { )} + {/* Storage quick info */} + {!storageLoading && storageData.length > 0 && ( +
+

Speicher

+
+ {storageData.map((sv) => { + const pct = sv.percentUsed ?? 0; + const barColor = pct >= 90 + ? 'var(--color-error, #ef4444)' + : pct >= 70 + ? 'var(--color-warning, #f59e0b)' + : 'var(--primary-color, #3b82f6)'; + return ( +
+

{sv.mandateName}

+
+ {formatBinaryDataSizeFromMebibytes(sv.usedMB)} + + / {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'} + +
+ {sv.maxDataVolumeMB != null && ( +
+
0 ? '3px' : '0', + }} /> +
+ )} + {sv.warning && ( +
+ Speicher knapp +
+ )} +
+ ); + })} +
+
+ )} + {/* Usage Statistics via FormGeneratorReport */}
{ {/* Tab: Statistik (Dashboard) */} {/* ================================================================ */} {activeTab === 'statistics' && ( -
- -
+ <> + {/* Storage volume section */} +
+
+

+ Speicherverbrauch +

+ {storageLoading ? ( +
Lade Speicherdaten...
+ ) : storageData.length === 0 ? ( +
Keine Speicherdaten verfügbar
+ ) : ( +
+ {storageData.map((sv) => { + const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB); + const maxLabel = sv.maxDataVolumeMB != null + ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) + : 'unbegrenzt'; + const pct = sv.percentUsed ?? 0; + const barColor = pct >= 90 + ? 'var(--color-error, #ef4444)' + : pct >= 70 + ? 'var(--color-warning, #f59e0b)' + : 'var(--primary-color, #3b82f6)'; + + return ( +
+
+ + {sv.mandateName} + + + {usedLabel} / {maxLabel} + {sv.percentUsed != null && ( + + ({pct.toFixed(1)}%) + + )} + +
+ {sv.maxDataVolumeMB != null && ( +
+
0 ? '4px' : '0', + }} /> +
+ )} +
+ Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)} + RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)} +
+
+ ); + })} +
+ )} +
+
+ + {/* AI usage statistics */} +
+ +
+ )} {/* ================================================================ */} diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx index bcb0231..22a5812 100644 --- a/src/pages/views/workspace/NeutralizationPanel.tsx +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -32,8 +32,9 @@ const NeutralizationPanel: React.FC = ({ instanceId }) setLoading(true); try { const response = await api.get(`/api/workspace/${instanceId}/files`); - const files = response.data?.data || response.data || []; - const neutralized = files + const raw = response.data; + const files = Array.isArray(raw) ? raw : (raw?.files || raw?.data || []); + const neutralized = (Array.isArray(files) ? files : []) .filter((f: any) => f.neutralize) .map((f: any) => ({ fileId: f.id,