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 ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE';
|
||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION';
|
||||
|
||||
export interface BillingBalance {
|
||||
mandateId: string;
|
||||
|
|
|
|||
|
|
@ -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<Automation2FlowEditorProps> = ({
|
|||
initialWorkflowId,
|
||||
}) => {
|
||||
const { request } = useApiRequest();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -115,8 +117,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
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<Automation2FlowEditorProps> = ({
|
|||
} 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<Automation2FlowEditorProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PromptDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
) : (
|
||||
<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 && (
|
||||
<span className={styles.actions}>
|
||||
{sel.onRenameFile && !multiSelected && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||
<FaPen />
|
||||
</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 />
|
||||
<span className={styles.rightZone}>
|
||||
<span className={styles.actions}>
|
||||
{sel.onRenameFile && !multiSelected && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||
<FaPen />
|
||||
</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>
|
||||
)}
|
||||
|
|
@ -328,6 +331,7 @@ interface TreeNodeProps {
|
|||
showFiles: boolean;
|
||||
filesByFolder: Map<string, FileNode[]>;
|
||||
sel: SelectionCtx;
|
||||
promptFolderName: (message: string) => Promise<string | null>;
|
||||
onToggle: (id: string) => void;
|
||||
onSelect: (id: string | null) => void;
|
||||
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
||||
|
|
@ -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<Set<string>>(new Set());
|
||||
const lastClickedIdRef = useRef<string | null>(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} />
|
||||
))}
|
||||
</div>
|
||||
<PromptDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -542,11 +542,11 @@ export const SettingsPage: React.FC = () => {
|
|||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||
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.
|
||||
</p>
|
||||
<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.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR oeffnen</Link></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 öffnen</Link></div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(null);
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(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<HTMLTableRowElement>, row: UserFile) => {
|
||||
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
||||
|
|
@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PromptDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
@ -365,6 +377,10 @@ export const BillingDataView: React.FC = () => {
|
|||
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
||||
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)
|
||||
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
||||
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<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(() => {
|
||||
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 = () => {
|
|||
)}
|
||||
</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 */}
|
||||
<section className={styles.section}>
|
||||
<FormGeneratorReport
|
||||
|
|
@ -602,18 +703,104 @@ export const BillingDataView: React.FC = () => {
|
|||
{/* Tab: Statistik (Dashboard) */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === '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>
|
||||
<>
|
||||
{/* Storage volume section */}
|
||||
<section className={styles.section}>
|
||||
<div className={styles.statisticsChart}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
Speicherverbrauch
|
||||
</h3>
|
||||
{storageLoading ? (
|
||||
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
|
||||
) : storageData.length === 0 ? (
|
||||
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
|
||||
) : (
|
||||
<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);
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue