unified failsafe neutralization architecture

This commit is contained in:
ValueOn AG 2026-03-29 21:55:13 +02:00
parent 0f0f43ce1b
commit 317e019b18
8 changed files with 305 additions and 90 deletions

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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;

View file

@ -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>
);
}

View file

@ -542,11 +542,11 @@ export const SettingsPage: React.FC = () => {
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Feature-Daten (z.&nbsp;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>
)}

View file

@ -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>
);
};

View file

@ -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<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>
</>
)}
{/* ================================================================ */}

View file

@ -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,