unified failsafe neutralization architecture
This commit is contained in:
parent
0f0f43ce1b
commit
317e019b18
8 changed files with 305 additions and 90 deletions
|
|
@ -5,7 +5,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
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 {
|
export interface BillingBalance {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
import { getCategoryIcon } from './utils';
|
import { getCategoryIcon } from './utils';
|
||||||
import { fromApiGraph, toApiGraph } from './graphUtils';
|
import { fromApiGraph, toApiGraph } from './graphUtils';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
const LOG = '[Automation2]';
|
||||||
|
|
@ -44,6 +45,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
initialWorkflowId,
|
initialWorkflowId,
|
||||||
}) => {
|
}) => {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -115,8 +117,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph });
|
await updateWorkflow(request, instanceId, currentWorkflowId, { graph });
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||||
} else {
|
} else {
|
||||||
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
|
const label = await promptInput('Workflow-Name:', { title: 'Workflow speichern', defaultValue: 'Neuer Workflow', placeholder: 'Name des Workflows' });
|
||||||
const created = await createWorkflow(request, instanceId, { label, graph });
|
if (!label) { setSaving(false); return; }
|
||||||
|
const created = await createWorkflow(request, instanceId, { label: label.trim() || 'Neuer Workflow', graph });
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
setWorkflows((prev) => [...prev, created]);
|
setWorkflows((prev) => [...prev, created]);
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||||
|
|
@ -126,7 +129,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput]);
|
||||||
|
|
||||||
const handleLoad = useCallback(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
|
|
@ -321,6 +324,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,21 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rightZone {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightZone .actions {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rootActions {
|
.rootActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
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';
|
import styles from './FolderTree.module.css';
|
||||||
|
|
||||||
/* ── Public types ──────────────────────────────────────────────────────── */
|
/* ── Public types ──────────────────────────────────────────────────────── */
|
||||||
|
|
@ -249,68 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.folderName}>{file.fileName}</span>
|
<span className={styles.folderName}>{file.fileName}</span>
|
||||||
)}
|
)}
|
||||||
{!renaming && file.fileSize != null && (
|
|
||||||
<span className={styles.fileSize}>
|
|
||||||
{(file.fileSize / 1024).toFixed(0)}K
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!renaming && file.scope != null && (
|
|
||||||
<span className={styles.scopeIcons}>
|
|
||||||
<button
|
|
||||||
className={styles.actionBtn}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!sel.onScopeChange) return;
|
|
||||||
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
|
|
||||||
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
|
||||||
sel.onScopeChange(file.id, next);
|
|
||||||
}}
|
|
||||||
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
|
|
||||||
style={{ fontSize: 14 }}
|
|
||||||
>
|
|
||||||
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={styles.actionBtn}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
|
|
||||||
}}
|
|
||||||
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
|
|
||||||
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
|
|
||||||
>
|
|
||||||
{'\uD83D\uDD12'}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!renaming && (
|
{!renaming && (
|
||||||
<span className={styles.actions}>
|
<span className={styles.rightZone}>
|
||||||
{sel.onRenameFile && !multiSelected && (
|
<span className={styles.actions}>
|
||||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
{sel.onRenameFile && !multiSelected && (
|
||||||
<FaPen />
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||||
</button>
|
<FaPen />
|
||||||
)}
|
|
||||||
{multiSelected && isSelected ? (
|
|
||||||
<>
|
|
||||||
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
|
||||||
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
|
||||||
<FaTrash />
|
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
|
||||||
<FaTrash />
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)}
|
||||||
|
{multiSelected && isSelected ? (
|
||||||
|
<>
|
||||||
|
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
||||||
|
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
||||||
|
<FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{file.fileSize != null && (
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{(file.fileSize / 1024).toFixed(0)}K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.scope != null && (
|
||||||
|
<span className={styles.scopeIcons}>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!sel.onScopeChange) return;
|
||||||
|
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
|
||||||
|
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
||||||
|
sel.onScopeChange(file.id, next);
|
||||||
|
}}
|
||||||
|
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
>
|
||||||
|
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
|
||||||
|
}}
|
||||||
|
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
|
||||||
|
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -328,6 +331,7 @@ interface TreeNodeProps {
|
||||||
showFiles: boolean;
|
showFiles: boolean;
|
||||||
filesByFolder: Map<string, FileNode[]>;
|
filesByFolder: Map<string, FileNode[]>;
|
||||||
sel: SelectionCtx;
|
sel: SelectionCtx;
|
||||||
|
promptFolderName: (message: string) => Promise<string | null>;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
||||||
|
|
@ -342,6 +346,7 @@ interface TreeNodeProps {
|
||||||
|
|
||||||
function _TreeNode({
|
function _TreeNode({
|
||||||
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
||||||
|
promptFolderName,
|
||||||
onToggle, onSelect,
|
onToggle, onSelect,
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onDownloadFolder,
|
onDownloadFolder,
|
||||||
|
|
@ -372,12 +377,12 @@ function _TreeNode({
|
||||||
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!onCreateFolder) return;
|
if (!onCreateFolder) return;
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) {
|
if (name?.trim()) {
|
||||||
await onCreateFolder(name.trim(), node.id);
|
await onCreateFolder(name.trim(), node.id);
|
||||||
if (!expandedIds.has(node.id)) onToggle(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) => {
|
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -539,6 +544,7 @@ function _TreeNode({
|
||||||
showFiles={showFiles}
|
showFiles={showFiles}
|
||||||
filesByFolder={filesByFolder}
|
filesByFolder={filesByFolder}
|
||||||
sel={sel}
|
sel={sel}
|
||||||
|
promptFolderName={promptFolderName}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onCreateFolder={onCreateFolder}
|
onCreateFolder={onCreateFolder}
|
||||||
|
|
@ -574,6 +580,7 @@ export default function FolderTree({
|
||||||
const [rootDropOver, setRootDropOver] = useState(false);
|
const [rootDropOver, setRootDropOver] = useState(false);
|
||||||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const lastClickedIdRef = useRef<string | null>(null);
|
const lastClickedIdRef = useRef<string | null>(null);
|
||||||
|
const { prompt: promptFolderName, PromptDialog } = usePrompt();
|
||||||
|
|
||||||
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
||||||
|
|
||||||
|
|
@ -753,7 +760,7 @@ export default function FolderTree({
|
||||||
className={styles.actionBtn}
|
className={styles.actionBtn}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
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);
|
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
||||||
}}
|
}}
|
||||||
title="Neuer Ordner"
|
title="Neuer Ordner"
|
||||||
|
|
@ -774,6 +781,7 @@ export default function FolderTree({
|
||||||
showFiles={showFiles}
|
showFiles={showFiles}
|
||||||
filesByFolder={filesByFolder}
|
filesByFolder={filesByFolder}
|
||||||
sel={sel}
|
sel={sel}
|
||||||
|
promptFolderName={promptFolderName}
|
||||||
onToggle={_handleToggle}
|
onToggle={_handleToggle}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onCreateFolder={onCreateFolder}
|
onCreateFolder={onCreateFolder}
|
||||||
|
|
@ -790,6 +798,7 @@ export default function FolderTree({
|
||||||
<_FileItem key={file.id} file={file} sel={sel} />
|
<_FileItem key={file.id} file={file} sel={sel} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -542,11 +542,11 @@ export const SettingsPage: React.FC = () => {
|
||||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich
|
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,
|
nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export,
|
||||||
Loeschung) finden Sie unter GDPR.
|
Löschung) finden Sie unter GDPR.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.settingRow}>
|
<div className={styles.settingRow}>
|
||||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>GDPR / Privacy</label><p className={styles.settingDescription}>Datenexport, Portabilitaet und Kontoloeschung.</p></div>
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>GDPR / Privacy</label><p className={styles.settingDescription}>Datenexport, Portabilität und Kontolöschung.</p></div>
|
||||||
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR oeffnen</Link></div>
|
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR öffnen</Link></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { FileNode } from '../../components/FolderTree/FolderTree';
|
||||||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||||
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
interface UserFile {
|
interface UserFile {
|
||||||
|
|
@ -31,6 +32,7 @@ interface UserFile {
|
||||||
export const FilesPage: React.FC = () => {
|
export const FilesPage: React.FC = () => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -223,11 +225,11 @@ export const FilesPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleNewFolder = useCallback(async () => {
|
const _handleNewFolder = useCallback(async () => {
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptInput('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) {
|
if (name?.trim()) {
|
||||||
await handleCreateFolder(name.trim(), selectedFolderId);
|
await handleCreateFolder(name.trim(), selectedFolderId);
|
||||||
}
|
}
|
||||||
}, [handleCreateFolder, selectedFolderId]);
|
}, [handleCreateFolder, selectedFolderId, promptInput]);
|
||||||
|
|
||||||
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
||||||
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
||||||
|
|
@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
||||||
import { UserTransaction } from '../../api/billingApi';
|
import { UserTransaction } from '../../api/billingApi';
|
||||||
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -42,6 +43,17 @@ interface ViewStatistics {
|
||||||
timeSeries: Array<{ date: string; cost: number; count: number }>;
|
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
|
// BALANCE CARD COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -364,7 +376,11 @@ export const BillingDataView: React.FC = () => {
|
||||||
// Statistics state (shared by Overview and Statistics tabs)
|
// Statistics state (shared by Overview and Statistics tabs)
|
||||||
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Storage volume state (for Statistics tab)
|
||||||
|
const [storageData, setStorageData] = useState<DataVolumeInfo[]>([]);
|
||||||
|
const [storageLoading, setStorageLoading] = useState(false);
|
||||||
|
|
||||||
// Transactions state (for Transactions tab)
|
// Transactions state (for Transactions tab)
|
||||||
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
||||||
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
||||||
|
|
@ -406,12 +422,47 @@ export const BillingDataView: React.FC = () => {
|
||||||
_loadViewStatistics(period, year, month);
|
_loadViewStatistics(period, year, month);
|
||||||
}, [_loadViewStatistics]);
|
}, [_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<string>();
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'overview' || activeTab === 'statistics') {
|
if (activeTab === 'overview' || activeTab === 'statistics') {
|
||||||
_loadViewStatistics('month', new Date().getFullYear());
|
_loadViewStatistics('month', new Date().getFullYear());
|
||||||
|
_loadStorageData();
|
||||||
}
|
}
|
||||||
}, [activeTab, _loadViewStatistics, selectedScope]);
|
}, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]);
|
||||||
|
|
||||||
// Load transactions with pagination support
|
// Load transactions with pagination support
|
||||||
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
||||||
|
|
@ -584,6 +635,56 @@ export const BillingDataView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Storage quick info */}
|
||||||
|
{!storageLoading && storageData.length > 0 && (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Speicher</h2>
|
||||||
|
<div className={styles.balanceGrid}>
|
||||||
|
{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 (
|
||||||
|
<div key={sv.mandateId} className={styles.balanceCard}>
|
||||||
|
<h3 className={styles.mandateName}>{sv.mandateName}</h3>
|
||||||
|
<div className={styles.balanceAmount} style={{ fontSize: '1.3rem' }}>
|
||||||
|
{formatBinaryDataSizeFromMebibytes(sv.usedMB)}
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', marginLeft: '6px' }}>
|
||||||
|
/ {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sv.maxDataVolumeMB != null && (
|
||||||
|
<div style={{
|
||||||
|
height: '6px',
|
||||||
|
background: 'var(--bg-secondary, #2a2a2a)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '10px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.min(pct, 100)}%`,
|
||||||
|
background: barColor,
|
||||||
|
borderRadius: '3px',
|
||||||
|
minWidth: pct > 0 ? '3px' : '0',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sv.warning && (
|
||||||
|
<div className={styles.warningBadge} style={{ marginTop: '8px' }}>
|
||||||
|
Speicher knapp
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Usage Statistics via FormGeneratorReport */}
|
{/* Usage Statistics via FormGeneratorReport */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<FormGeneratorReport
|
<FormGeneratorReport
|
||||||
|
|
@ -602,18 +703,104 @@ export const BillingDataView: React.FC = () => {
|
||||||
{/* Tab: Statistik (Dashboard) */}
|
{/* Tab: Statistik (Dashboard) */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{activeTab === 'statistics' && (
|
{activeTab === 'statistics' && (
|
||||||
<section className={styles.section}>
|
<>
|
||||||
<FormGeneratorReport
|
{/* Storage volume section */}
|
||||||
title="Nutzungsstatistik"
|
<section className={styles.section}>
|
||||||
subtitle="Detaillierte Analyse der AI-Nutzung"
|
<div className={styles.statisticsChart}>
|
||||||
periodSelector={periodSelectorConfig}
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||||
onFilterChange={_handleStatsFilterChange}
|
Speicherverbrauch
|
||||||
loading={statsLoading}
|
</h3>
|
||||||
sections={statisticsSections}
|
{storageLoading ? (
|
||||||
noDataMessage="Keine Statistiken verfügbar"
|
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
|
||||||
currencyCode="CHF"
|
) : storageData.length === 0 ? (
|
||||||
/>
|
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
|
||||||
</section>
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{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 (
|
||||||
|
<div key={sv.mandateId} style={{
|
||||||
|
background: 'var(--bg-secondary, #2a2a2a)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '14px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
||||||
|
{sv.mandateName}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', fontFamily: 'monospace' }}>
|
||||||
|
{usedLabel} / {maxLabel}
|
||||||
|
{sv.percentUsed != null && (
|
||||||
|
<span style={{ marginLeft: '8px', color: barColor, fontWeight: 600 }}>
|
||||||
|
({pct.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sv.maxDataVolumeMB != null && (
|
||||||
|
<div style={{
|
||||||
|
height: '10px',
|
||||||
|
background: 'var(--surface-color, #1e1e1e)',
|
||||||
|
borderRadius: '5px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.min(pct, 100)}%`,
|
||||||
|
background: barColor,
|
||||||
|
borderRadius: '5px',
|
||||||
|
transition: 'width 0.4s ease',
|
||||||
|
minWidth: pct > 0 ? '4px' : '0',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--text-secondary, #888)',
|
||||||
|
}}>
|
||||||
|
<span>Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)}</span>
|
||||||
|
<span>RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* AI usage statistics */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<FormGeneratorReport
|
||||||
|
title="Nutzungsstatistik"
|
||||||
|
subtitle="Detaillierte Analyse der AI-Nutzung"
|
||||||
|
periodSelector={periodSelectorConfig}
|
||||||
|
onFilterChange={_handleStatsFilterChange}
|
||||||
|
loading={statsLoading}
|
||||||
|
sections={statisticsSections}
|
||||||
|
noDataMessage="Keine Statistiken verfügbar"
|
||||||
|
currencyCode="CHF"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,9 @@ const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId })
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/workspace/${instanceId}/files`);
|
const response = await api.get(`/api/workspace/${instanceId}/files`);
|
||||||
const files = response.data?.data || response.data || [];
|
const raw = response.data;
|
||||||
const neutralized = files
|
const files = Array.isArray(raw) ? raw : (raw?.files || raw?.data || []);
|
||||||
|
const neutralized = (Array.isArray(files) ? files : [])
|
||||||
.filter((f: any) => f.neutralize)
|
.filter((f: any) => f.neutralize)
|
||||||
.map((f: any) => ({
|
.map((f: any) => ({
|
||||||
fileId: f.id,
|
fileId: f.id,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue