= ({
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
>
{balance.mandateName || balance.mandateId}
- {getBillingModelLabel(balance.billingModel)}
{balance.userCount}
- {formatCurrency(balance.defaultUserCredit)}
+ {balance.warningThresholdPercent}%
{formatCurrency(balance.totalBalance)}
void;
}
+const _DEFAULT_STT_LANGUAGE = 'de-DE';
+
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState('idle');
const [muted, setMuted] = useState(false);
@@ -38,6 +42,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
+ const sttLanguageRef = useRef(_DEFAULT_STT_LANGUAGE);
+
+ useEffect(() => {
+ let cancelled = false;
+ api.get('/api/local/voice-preferences').then((res) => {
+ if (cancelled) return;
+ const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
+ if (lang) sttLanguageRef.current = lang;
+ }).catch(() => {});
+ return () => { cancelled = true; };
+ }, []);
+
const _dlog = useCallback((tag: string, info?: string) => {
const t = new Date();
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
@@ -68,16 +84,20 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
onError: (err) => _dlog('VOICE-ERR', String(err)),
});
+ const _startStream = useCallback(() => {
+ return voiceStream.start(sttLanguageRef.current);
+ }, [voiceStream]);
+
const activate = useCallback(async () => {
if (stateRef.current !== 'idle') return;
_setState('listening');
try {
- await voiceStream.start('de-DE');
+ await _startStream();
} catch (err) {
_dlog('MIC-ERR', String(err));
_setState('idle');
}
- }, [_setState, voiceStream, _dlog]);
+ }, [_setState, _startStream, _dlog]);
const deactivate = useCallback(() => {
voiceStream.stop();
@@ -94,15 +114,15 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
const ttsPaused = useCallback(() => {
if (stateRef.current !== 'botSpeaking') return;
_setState('interrupted');
- voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
- }, [_setState, voiceStream, _dlog]);
+ _startStream().catch((err) => _dlog('MIC-ERR', String(err)));
+ }, [_setState, _startStream, _dlog]);
const ttsEnded = useCallback(() => {
const cur = stateRef.current;
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
_setState('listening');
- voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
- }, [_setState, voiceStream, _dlog]);
+ _startStream().catch((err) => _dlog('MIC-ERR', String(err)));
+ }, [_setState, _startStream, _dlog]);
const toggleMute = useCallback(() => {
const cur = stateRef.current;
@@ -110,13 +130,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
if (mutedRef.current) {
_setMuted(false);
if (cur === 'listening' || cur === 'interrupted') {
- voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
+ _startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}
} else {
_setMuted(true);
voiceStream.stop();
}
- }, [_setMuted, voiceStream, _dlog]);
+ }, [_setMuted, _startStream, voiceStream, _dlog]);
return {
state,
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx
index 9b16849..a7057eb 100644
--- a/src/pages/views/workspace/WorkspaceInput.tsx
+++ b/src/pages/views/workspace/WorkspaceInput.tsx
@@ -53,6 +53,8 @@ interface WorkspaceInputProps {
isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onPasteAsFile?: (file: File) => void;
+ draftAppend?: string;
+ onDraftAppendConsumed?: () => void;
}
export const WorkspaceInput: React.FC = ({
@@ -72,6 +74,8 @@ export const WorkspaceInput: React.FC = ({
isMobile = false,
onTreeItemsDrop,
onPasteAsFile,
+ draftAppend,
+ onDraftAppendConsumed,
}) => {
const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
@@ -86,6 +90,14 @@ export const WorkspaceInput: React.FC = ({
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]);
const [neutralizeActive, setNeutralizeActive] = useState(false);
const textareaRef = useRef(null);
+
+ useEffect(() => {
+ if (draftAppend) {
+ setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
+ onDraftAppendConsumed?.();
+ }
+ }, [draftAppend, onDraftAppendConsumed]);
+
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');
diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx
index 3717c65..01f965b 100644
--- a/src/pages/views/workspace/WorkspacePage.tsx
+++ b/src/pages/views/workspace/WorkspacePage.tsx
@@ -83,6 +83,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance
const [pendingFiles, setPendingFiles] = useState([]);
const [selectedProviders, setSelectedProviders] = useState([]);
const [isDragOver, setIsDragOver] = useState(false);
+ const [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0);
const fileInputRef = useRef(null);
const [isMobile, setIsMobile] = useState(() =>
@@ -142,13 +143,28 @@ export const WorkspacePage: React.FC = ({ persistentInstance
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragOver(false);
+
+ const chatId = e.dataTransfer.getData('application/chat-id');
+ if (chatId) {
+ try {
+ const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId });
+ const body = res.data ?? {};
+ if (body.summary) {
+ setDraftAppend(body.summary);
+ }
+ } catch (err) {
+ console.error('RAG resolve failed for dropped chat:', err);
+ }
+ return;
+ }
+
const droppedFiles = e.dataTransfer.files;
if (droppedFiles.length > 0) {
for (const file of Array.from(droppedFiles)) {
await _uploadAndAttach(file);
}
}
- }, [_uploadAndAttach]);
+ }, [_uploadAndAttach, instanceId, workspace]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent) => {
if (e.target.files && e.target.files.length > 0) {
@@ -396,9 +412,9 @@ export const WorkspacePage: React.FC = ({ persistentInstance
/>
{
+ onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
- workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds);
+ workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds, options);
setPendingFiles([]);
}}
isProcessing={workspace.isProcessing}
@@ -415,6 +431,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onPasteAsFile={_uploadAndAttach}
+ draftAppend={draftAppend}
+ onDraftAppendConsumed={() => setDraftAppend('')}
/>
diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts
index fcbb430..c5dc589 100644
--- a/src/pages/views/workspace/useWorkspace.ts
+++ b/src/pages/views/workspace/useWorkspace.ts
@@ -95,7 +95,7 @@ export interface DataSourceAccessEvent {
interface UseWorkspaceReturn {
messages: Message[];
isProcessing: boolean;
- sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[]) => void;
+ sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void;
stopProcessing: () => void;
loadWorkflow: (workflowId: string) => void;
resetToNew: () => void;
@@ -197,7 +197,7 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
}, []);
const sendMessage = useCallback(
- (prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = []) => {
+ (prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = [], options?: { requireNeutralization?: boolean }) => {
if (!instanceId || isProcessing) return;
setIsProcessing(true);
@@ -242,6 +242,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
if (allowedProviders.length > 0) {
body.allowedProviders = allowedProviders;
}
+ if (options?.requireNeutralization !== undefined) {
+ body.requireNeutralization = options.requireNeutralization;
+ }
cleanupRef.current = startSseStream({
url,
diff --git a/src/utils/mandateBillingFormMerge.ts b/src/utils/mandateBillingFormMerge.ts
index ce06580..4cc916f 100644
--- a/src/utils/mandateBillingFormMerge.ts
+++ b/src/utils/mandateBillingFormMerge.ts
@@ -7,8 +7,6 @@ import type { AttributeDefinition } from '../components/FormGenerator/FormGenera
import type { BillingSettings, BillingSettingsUpdate } from '../api/billingApi';
export const mandateBillingFieldNames = [
- 'billingModel',
- 'defaultUserCredit',
'warningThresholdPercent',
'notifyOnWarning',
'notifyEmails',
@@ -19,31 +17,6 @@ export type MandateBillingFieldName = (typeof mandateBillingFieldNames)[number];
/** FormGenerator attribute definitions for mandate billing (appended after /api/attributes/Mandate). */
export function getMandateBillingFormAttributes(): AttributeDefinition[] {
return [
- {
- name: 'billingModel',
- type: 'select',
- label: 'Abrechnungsmodell',
- description: 'Vorauszahlung auf Mandanten- oder Benutzerkonten.',
- required: false,
- default: 'PREPAY_MANDATE',
- editable: true,
- order: 100,
- options: [
- { value: 'PREPAY_MANDATE', label: 'Vorauszahlung (Mandanten-Guthaben)' },
- { value: 'PREPAY_USER', label: 'Vorauszahlung pro Benutzer' },
- ],
- },
- {
- name: 'defaultUserCredit',
- type: 'float',
- label: 'Startguthaben neuem Benutzer (CHF)',
- description:
- 'Nur relevant bei PREPAY_USER (u. a. Root-Mandant). Sonst meist 0.',
- required: false,
- default: 0,
- editable: true,
- order: 101,
- },
{
name: 'warningThresholdPercent',
type: 'float',
@@ -61,7 +34,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] {
required: false,
default: true,
editable: true,
- order: 103,
+ order: 102,
},
{
name: 'notifyEmails',
@@ -71,7 +44,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] {
required: false,
default: '',
editable: true,
- order: 104,
+ order: 103,
minRows: 2,
maxRows: 6,
},
@@ -91,12 +64,6 @@ function _parseNotifyEmailsInput(val: unknown): string[] {
.filter(Boolean);
}
-/** Build initial form values: mandate row + billing settings (notifyEmails as multi-line string). */
-function _normalizeBillingModelUi(raw: string | undefined): BillingSettings['billingModel'] {
- if (raw === 'PREPAY_USER') return 'PREPAY_USER';
- return 'PREPAY_MANDATE';
-}
-
export function mergeBillingIntoMandateFormData(
mandate: Record,
settings: BillingSettings | null
@@ -104,8 +71,6 @@ export function mergeBillingIntoMandateFormData(
if (!settings) {
return {
...mandate,
- billingModel: 'PREPAY_MANDATE',
- defaultUserCredit: 0,
warningThresholdPercent: 10,
notifyOnWarning: true,
notifyEmails: '',
@@ -113,8 +78,6 @@ export function mergeBillingIntoMandateFormData(
}
return {
...mandate,
- billingModel: _normalizeBillingModelUi(settings.billingModel),
- defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true,
notifyEmails: (settings.notifyEmails || []).join('\n'),
@@ -131,19 +94,6 @@ export function splitMandateAndBillingFromForm(
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
const billingUpdate: BillingSettingsUpdate = {};
- if ('billingModel' in formData && formData.billingModel !== undefined && formData.billingModel !== '') {
- billingUpdate.billingModel = formData.billingModel as BillingSettingsUpdate['billingModel'];
- }
- {
- const raw = formData.defaultUserCredit;
- const n =
- raw === undefined || raw === null || raw === ''
- ? 0
- : Number(raw);
- if (!Number.isNaN(n)) {
- billingUpdate.defaultUserCredit = n;
- }
- }
if (
'warningThresholdPercent' in formData &&
formData.warningThresholdPercent !== undefined &&
From 0f0f43ce1b5027540a72a827783fedc3460269d2 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 29 Mar 2026 12:18:56 +0200
Subject: [PATCH 08/11] streamlined billing incl ai and storage budget
---
src/api/billingApi.ts | 8 +-
src/api/storeApi.ts | 1 +
src/api/subscriptionApi.ts | 2 +
.../NotificationBell/NotificationBell.tsx | 8 +-
.../ConnectedFilesList/ConnectedFilesList.tsx | 11 +--
.../UiComponents/Messages/MessageUtils.ts | 14 +--
src/hooks/useConfirm.tsx | 2 +-
src/hooks/useNotifications.ts | 49 ++++++++++-
src/pages/Settings.tsx | 12 +--
src/pages/Store.tsx | 9 +-
src/pages/billing/BillingAdmin.tsx | 78 +++++++++++++----
src/pages/billing/BillingDataView.tsx | 85 +++++++------------
src/pages/billing/SubscriptionTab.tsx | 41 ++++++++-
.../views/commcoach/useVoiceController.ts | 4 +-
src/pages/views/workspace/ChatStream.tsx | 7 +-
src/pages/views/workspace/FilePreview.tsx | 8 +-
.../views/workspace/WorkspaceEditorPage.tsx | 11 +--
src/pages/views/workspace/WorkspaceInput.tsx | 4 +-
.../workspace/WorkspaceRagInsightsPage.tsx | 15 +---
src/utils/formatDataSize.ts | 43 ++++++++++
20 files changed, 271 insertions(+), 141 deletions(-)
create mode 100644 src/utils/formatDataSize.ts
diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts
index 3a99597..326fc25 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';
+export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE';
export interface BillingBalance {
mandateId: string;
@@ -42,12 +42,18 @@ export interface BillingSettings {
warningThresholdPercent: number;
notifyOnWarning: boolean;
notifyEmails: string[];
+ autoRechargeEnabled?: boolean;
+ rechargeAmountCHF?: number;
+ rechargeMaxPerMonth?: number;
}
export interface BillingSettingsUpdate {
warningThresholdPercent?: number;
notifyOnWarning?: boolean;
notifyEmails?: string[];
+ autoRechargeEnabled?: boolean;
+ rechargeAmountCHF?: number;
+ rechargeMaxPerMonth?: number;
}
export interface UsageReport {
diff --git a/src/api/storeApi.ts b/src/api/storeApi.ts
index deb8f1e..78b0768 100644
--- a/src/api/storeApi.ts
+++ b/src/api/storeApi.ts
@@ -49,6 +49,7 @@ export interface SubscriptionInfo {
status: string | null;
maxDataVolumeMB: number | null;
maxFeatureInstances: number | null;
+ budgetAiCHF: number | null;
currentFeatureInstances: number;
trialEndsAt: string | null;
}
diff --git a/src/api/subscriptionApi.ts b/src/api/subscriptionApi.ts
index 9fefe9f..b476433 100644
--- a/src/api/subscriptionApi.ts
+++ b/src/api/subscriptionApi.ts
@@ -19,6 +19,8 @@ export interface SubscriptionPlan {
autoRenew: boolean;
maxUsers: number | null;
maxFeatureInstances: number | null;
+ maxDataVolumeMB?: number | null;
+ budgetAiCHF?: number;
trialDays: number | null;
successorPlanKey: string | null;
}
diff --git a/src/components/NotificationBell/NotificationBell.tsx b/src/components/NotificationBell/NotificationBell.tsx
index baa33a1..d0f237f 100644
--- a/src/components/NotificationBell/NotificationBell.tsx
+++ b/src/components/NotificationBell/NotificationBell.tsx
@@ -18,8 +18,11 @@ const typeIcons: Record = {
mention:
};
-// Format timestamp to relative time
+// Format timestamp to relative time (Unix seconds)
function formatRelativeTime(timestamp: number): string {
+ if (!Number.isFinite(timestamp) || timestamp <= 0) {
+ return '';
+ }
const now = Date.now() / 1000;
const diff = now - timestamp;
@@ -29,6 +32,9 @@ function formatRelativeTime(timestamp: number): string {
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
const date = new Date(timestamp * 1000);
+ if (Number.isNaN(date.getTime())) {
+ return '';
+ }
return date.toLocaleDateString('de-DE');
}
diff --git a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx
index 371f650..77cf5cd 100644
--- a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx
+++ b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx
@@ -10,6 +10,7 @@ import {
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css';
+import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
export interface ConnectedFilesListActionButton {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
@@ -240,7 +241,7 @@ export function ConnectedFilesList({
- {formatFileSize(file.fileSize)}
+ {formatBinaryDataSizeBytes(file.fileSize)}
{file.source && (
@@ -371,13 +372,5 @@ export function ConnectedFilesList({
);
}
-function formatFileSize(bytes: number): string {
- if (bytes === 0) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
-}
-
export default ConnectedFilesList;
diff --git a/src/components/UiComponents/Messages/MessageUtils.ts b/src/components/UiComponents/Messages/MessageUtils.ts
index 3d40945..ff67d51 100644
--- a/src/components/UiComponents/Messages/MessageUtils.ts
+++ b/src/components/UiComponents/Messages/MessageUtils.ts
@@ -2,6 +2,8 @@
* Utility functions for message formatting and styling
*/
+import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
+
/**
* Formats a timestamp to a readable date/time string
* Handles both Unix timestamps in seconds and milliseconds
@@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => {
}
};
-/**
- * Formats file size to human-readable format
- */
-export const formatFileSize = (bytes: number): string => {
- if (bytes === 0) return '0 Bytes';
- const k = 1024;
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
-};
+/** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */
+export const formatFileSize = formatBinaryDataSizeBytes;
/**
* Gets status badge color class based on status
diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx
index bb9fbab..ec190d7 100644
--- a/src/hooks/useConfirm.tsx
+++ b/src/hooks/useConfirm.tsx
@@ -104,7 +104,7 @@ export function useConfirm() {
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
border: '1px solid var(--color-border, #444)',
background: 'transparent',
- color: 'var(--text-secondary, #aaa)',
+ color: 'var(--text-primary, #e8e8e8)',
cursor: 'pointer',
}}
>
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 58a989b..b3706d9 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -10,6 +10,48 @@ import api from '../api';
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
+/** API uses PowerOnModel.sysCreatedAt (seconds); legacy clients used createdAt. */
+function _coerceToUnixSeconds(value: unknown): number | undefined {
+ if (value == null) return undefined;
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value > 1e12 ? value / 1000 : value;
+ }
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ if (!trimmed) return undefined;
+ const asNum = Number(trimmed);
+ if (!Number.isNaN(asNum)) {
+ return asNum > 1e12 ? asNum / 1000 : asNum;
+ }
+ const parsed = Date.parse(trimmed);
+ if (!Number.isNaN(parsed)) return parsed / 1000;
+ }
+ return undefined;
+}
+
+function _normalizeNotificationFromApi(raw: Record): UserNotification {
+ const partial = raw as unknown as UserNotification;
+ const createdAt =
+ _coerceToUnixSeconds(raw.createdAt) ??
+ _coerceToUnixSeconds(raw.sysCreatedAt) ??
+ (Number.isFinite(partial.createdAt) ? partial.createdAt : 0) ??
+ 0;
+ return {
+ ...partial,
+ createdAt,
+ readAt: _coerceToUnixSeconds(raw.readAt) ?? partial.readAt,
+ actionedAt: _coerceToUnixSeconds(raw.actionedAt) ?? partial.actionedAt,
+ expiresAt: _coerceToUnixSeconds(raw.expiresAt) ?? partial.expiresAt,
+ };
+}
+
+function _normalizeNotificationList(data: unknown): UserNotification[] {
+ if (!Array.isArray(data)) return [];
+ return data.map(item =>
+ _normalizeNotificationFromApi(item && typeof item === 'object' ? (item as Record) : {})
+ );
+}
+
// Types
export interface NotificationAction {
actionId: string;
@@ -30,6 +72,7 @@ export interface UserNotification {
actions?: NotificationAction[];
actionTaken?: string;
actionResult?: string;
+ /** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */
createdAt: number;
readAt?: number;
actionedAt?: number;
@@ -74,7 +117,7 @@ export function useNotifications() {
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url);
- const data = response.data as UserNotification[];
+ const data = _normalizeNotificationList(response.data);
setNotifications(data);
return data;
} catch (err: any) {
@@ -101,9 +144,9 @@ export function useNotifications() {
const listRes = await api.get('/api/notifications', {
params: { status: 'unread', limit: 25 },
});
- const list = listRes.data as UserNotification[];
+ const list = _normalizeNotificationList(listRes.data);
if (
- Array.isArray(list) &&
+ list.length > 0 &&
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
) {
window.dispatchEvent(new Event('features-changed'));
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 94f73ce..ff8af52 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -107,8 +107,8 @@ const VoiceSettingsTab: React.FC = () => {
setLoading(true);
try {
const [prefsData, languagesData] = await Promise.all([
- request({ url: '/api/local/voice-preferences', method: 'get' }),
- request({ url: '/api/local/voice/languages', method: 'get' }),
+ request({ url: '/api/voice/preferences', method: 'get' }),
+ request({ url: '/api/voice/languages', method: 'get' }),
]);
const langList = (languagesData as any)?.languages || [];
@@ -135,7 +135,7 @@ const VoiceSettingsTab: React.FC = () => {
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
setLoadingVoices(true);
try {
- const result = await request({ url: '/api/local/voice/voices', method: 'get', params: { language: lang } });
+ const result = await request({ url: '/api/voice/voices', method: 'get', params: { language: lang } });
setAddVoices((result as any)?.voices || []);
setAddVoiceName('');
} catch { setAddVoices([]); }
@@ -167,7 +167,7 @@ const VoiceSettingsTab: React.FC = () => {
const mapObj: Record = {};
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
await request({
- url: '/api/local/voice-preferences',
+ url: '/api/voice/preferences',
method: 'put',
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
});
@@ -185,9 +185,9 @@ const VoiceSettingsTab: React.FC = () => {
setTesting(lang);
try {
const result: any = await request({
- url: '/api/local/voice/test',
+ url: '/api/voice/test',
method: 'post',
- data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` },
+ data: { language: lang, voiceId: voice || undefined },
});
if (result?.success && result?.audio) {
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx
index 1162d26..ee605c1 100644
--- a/src/pages/Store.tsx
+++ b/src/pages/Store.tsx
@@ -9,6 +9,7 @@ import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'
import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore';
import type { StoreFeature, UserMandate } from '../api/storeApi';
+import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
import styles from './Store.module.css';
const FEATURE_ICONS: Record = {
@@ -176,7 +177,13 @@ const StorePage: React.FC = () => {
)}
{subscriptionInfo.maxDataVolumeMB != null && (
- {currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB
+ {currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '}
+ {formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
+
+ )}
+ {subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && (
+
+ {currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF
)}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx
index 62245fd..07c3be0 100644
--- a/src/pages/billing/BillingAdmin.tsx
+++ b/src/pages/billing/BillingAdmin.tsx
@@ -87,6 +87,9 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi
const [formData, setFormData] = useState({
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
notifyOnWarning: settings?.notifyOnWarning ?? true,
+ autoRechargeEnabled: settings?.autoRechargeEnabled ?? false,
+ rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10),
+ rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3),
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -96,6 +99,9 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi
setFormData({
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true,
+ autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
+ rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
+ rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
});
}
}, [settings]);
@@ -154,6 +160,49 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi
+
+
+
+
+ setFormData(prev => ({ ...prev, autoRechargeEnabled: e.target.checked }))}
+ />
+ Auto-Nachladung (AI-Guthaben bei niedrigem Stand)
+
+
+
+ {formData.autoRechargeEnabled && (
+
+ )}
= ({ onAddCredit }) => {
interface AccountsOverviewProps {
accounts: AccountSummary[];
- users: MandateUserSummary[];
+ /** Kept for call-site compatibility; only mandate pool accounts are shown. */
+ users?: MandateUserSummary[];
loading: boolean;
}
-const AccountsOverview: React.FC = ({ accounts, users, loading }) => {
+const AccountsOverview: React.FC = ({ accounts, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
@@ -272,19 +322,8 @@ const AccountsOverview: React.FC = ({ accounts, users, lo
}).format(amount);
};
- // Build a lookup map: userId -> display name
- const _userNameMap = useMemo(() => {
- const map = new Map();
- for (const user of users) {
- const displayName = user.displayName
- || [user.firstName, user.lastName].filter(Boolean).join(' ')
- || user.username
- || user.id;
- map.set(user.id, displayName);
- }
- return map;
- }, [users]);
-
+ const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
+
if (loading) {
return Lade Konten...
;
}
@@ -292,16 +331,19 @@ const AccountsOverview: React.FC = ({ accounts, users, lo
if (accounts.length === 0) {
return Keine Konten vorhanden
;
}
+
+ if (poolAccounts.length === 0) {
+ return Kein Mandanten-Konto vorhanden
;
+ }
return (
Konten
- {accounts.map((account) => (
+ {poolAccounts.map((account) => (
-
{!account.userId ? 'Mandanten-Konto' : 'Benutzer-Konto'}
+
Mandanten-Konto
- {account.userId && User: {_userNameMap.get(account.userId) || account.userId} }
Guthaben: {formatCurrency(account.balance)}
Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}
diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx
index 5846177..4cd1bb3 100644
--- a/src/pages/billing/BillingDataView.tsx
+++ b/src/pages/billing/BillingDataView.tsx
@@ -8,7 +8,7 @@
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { useSearchParams, useNavigate } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
@@ -48,13 +48,34 @@ interface ViewStatistics {
interface BalanceCardProps {
balance: BillingBalance;
+ onOpenMandateAdmin?: (mandateId: string) => void;
}
-const BalanceCard: React.FC
= ({ balance }) => {
+const BalanceCard: React.FC = ({ balance, onOpenMandateAdmin }) => {
return (
-
{balance.mandateName}
+ {onOpenMandateAdmin ? (
+ onOpenMandateAdmin(balance.mandateId)}
+ style={{
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ cursor: 'pointer',
+ textAlign: 'left',
+ font: 'inherit',
+ color: 'inherit',
+ textDecoration: 'underline',
+ }}
+ >
+ {balance.mandateName}
+
+ ) : (
+ {balance.mandateName}
+ )}
{_formatCurrency(balance.balance)}
@@ -267,7 +288,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState
('overview');
const [searchParams, setSearchParams] = useSearchParams();
+ const navigate = useNavigate();
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
+
+ const _openMandateBillingAdmin = useCallback((mandateId: string) => {
+ navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`);
+ }, [navigate]);
// Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState('personal');
@@ -335,10 +361,6 @@ export const BillingDataView: React.FC = () => {
setCheckoutMessage(null);
}, [searchParams, setSearchParams]);
- // All user balances (for admin overview cards)
- const [allUserBalances, setAllUserBalances] = useState([]);
- const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
-
// Statistics state (shared by Overview and Statistics tabs)
const [viewStats, setViewStats] = useState(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -349,19 +371,6 @@ export const BillingDataView: React.FC = () => {
const [transactionsError, setTransactionsError] = useState(null);
const [transactionsPagination, setTransactionsPagination] = useState(null);
- // Load all user balances for admin overview
- const _loadAllUserBalances = useCallback(async () => {
- try {
- setAllUserBalancesLoading(true);
- const response = await api.get('/api/billing/view/users/balances');
- setAllUserBalances(Array.isArray(response.data) ? response.data : []);
- } catch {
- setAllUserBalances([]);
- } finally {
- setAllUserBalancesLoading(false);
- }
- }, []);
-
// Load aggregated statistics from the view/statistics route
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
try {
@@ -402,10 +411,7 @@ export const BillingDataView: React.FC = () => {
if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear());
}
- if (activeTab === 'overview') {
- _loadAllUserBalances();
- }
- }, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
+ }, [activeTab, _loadViewStatistics, selectedScope]);
// Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => {
@@ -555,12 +561,6 @@ export const BillingDataView: React.FC = () => {
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
? balances
: balances.filter(b => b.mandateId === selectedScope);
-
- const filteredUserBalances = selectedScope === 'personal'
- ? [] // personal view: only own balance cards, no other users
- : selectedScope === 'all'
- ? allUserBalances
- : allUserBalances.filter(ub => ub.mandateId === selectedScope);
return (
<>
@@ -577,36 +577,13 @@ export const BillingDataView: React.FC = () => {
))}
)}
- {/* All User Balance Cards (mandate/all scope) */}
- {filteredUserBalances.length > 0 && (
-
- Benutzer-Guthaben
- {allUserBalancesLoading ? (
- Lade Benutzer-Guthaben...
- ) : (
-
- {filteredUserBalances.map((ub, idx) => (
-
-
-
{ub.userName || ub.userId?.slice(0, 8)}
- {ub.mandateName}
-
-
- {_formatCurrency(ub.balance || 0)}
-
-
- ))}
-
- )}
-
- )}
-
{/* Usage Statistics via FormGeneratorReport */}
= {
NONE: '—',
};
+/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
+const storageOverageChfPerGbMonth = 0.5;
+
// ============================================================================
// Plan Card
// ============================================================================
@@ -98,6 +102,19 @@ const PlanCard: React.FC = ({ plan, isCurrent, onActivate, activa
User: {_formatCurrency(plan.pricePerUserCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
Instanz: {_formatCurrency(plan.pricePerFeatureInstanceCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
+
+ AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode
+ {' · '}
+ Speicher (inkl.):{' '}
+
+ {plan.maxDataVolumeMB == null
+ ? 'unbegrenzt'
+ : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
+
+
+
+ Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
+
)}
@@ -106,6 +123,14 @@ const PlanCard: React.FC = ({ plan, isCurrent, onActivate, activa
{plan.trialDays} Tage kostenlos
{plan.maxUsers && <> · max. {plan.maxUsers} User>}
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen>}
+ {(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && (
+ <>
+ {plan.maxDataVolumeMB != null && (
+ <> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}>
+ )}
+ {(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}>}
+ >
+ )}
)}
@@ -214,6 +239,20 @@ const SubInfoCard: React.FC = ({ sub, plan, label, onCancel, onRea
{isActive && !sub.recurring && sub.currentPeriodEnd && (
Läuft aus am: {_formatDate(sub.currentPeriodEnd)}
)}
+ {plan && (
+ <>
+ AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode
+
+ Speicher (inkl.):{' '}
+ {plan.maxDataVolumeMB == null
+ ? 'unbegrenzt'
+ : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
+
+
+ Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
+
+ >
+ )}
)}
@@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC
= ({ mandateId }) =
{
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
- cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
+ cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
variant: 'danger',
},
);
diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts
index c030e9c..5e9e8c8 100644
--- a/src/pages/views/commcoach/useVoiceController.ts
+++ b/src/pages/views/commcoach/useVoiceController.ts
@@ -6,7 +6,7 @@
*
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
* Google Streaming STT handles silence detection natively.
- * STT language is loaded from central voice preferences (/api/local/voice-preferences).
+ * STT language is loaded from central voice preferences (/api/voice/preferences).
*/
import { useState, useRef, useCallback, useEffect } from 'react';
@@ -46,7 +46,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
useEffect(() => {
let cancelled = false;
- api.get('/api/local/voice-preferences').then((res) => {
+ api.get('/api/voice/preferences').then((res) => {
if (cancelled) return;
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
if (lang) sttLanguageRef.current = lang;
diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx
index 07494d3..a66bc3b 100644
--- a/src/pages/views/workspace/ChatStream.tsx
+++ b/src/pages/views/workspace/ChatStream.tsx
@@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import api from '../../../api';
+import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { AgentProgress, FileEditProposal } from './useWorkspace';
@@ -339,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
const icon = _getFileIcon(ext);
- const sizeLabel = doc.fileSize
- ? doc.fileSize > 1024 * 1024
- ? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
- : `${(doc.fileSize / 1024).toFixed(1)} KB`
- : '';
+ const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
return (
= ({ instanceId, fileId, fi
{file.mimeType}
- {_formatFileSize(file.fileSize)}
+ {formatBinaryDataSizeBytes(file.fileSize)}
{file.status && {file.status} }
{file.description && (
@@ -146,8 +147,3 @@ function _isTextMime(mime: string): boolean {
return textTypes.includes(mime);
}
-function _formatFileSize(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
diff --git a/src/pages/views/workspace/WorkspaceEditorPage.tsx b/src/pages/views/workspace/WorkspaceEditorPage.tsx
index 8cb7b5d..7629520 100644
--- a/src/pages/views/workspace/WorkspaceEditorPage.tsx
+++ b/src/pages/views/workspace/WorkspaceEditorPage.tsx
@@ -15,6 +15,7 @@ import type { editor as monacoEditor } from 'monaco-editor';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
+import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
function _getMonacoLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
@@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string {
return langMap[ext] || 'plaintext';
}
-function _formatBytes(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
-
export const WorkspaceEditorPage: React.FC = () => {
const instanceId = useInstanceId() || '';
const navigate = useNavigate();
@@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => {
}}>
{activeEdit.fileName}
- Original: {_formatBytes(activeEdit.oldContent.length)}
- Geaendert: {_formatBytes(activeEdit.newContent.length)}
+ Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)}
+ Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}
= ({
useEffect(() => {
if (_sttPrefsLoaded.current) return;
_sttPrefsLoaded.current = true;
- fetch('/api/local/voice-preferences', { credentials: 'include' })
+ fetch('/api/voice/preferences', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
.catch(() => {});
@@ -702,7 +702,7 @@ export const WorkspaceInput: React.FC = ({
onClick={() => {
setVoiceLanguage(lang.code);
setShowLangPicker(false);
- fetch('/api/local/voice-preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
+ fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
}}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
diff --git a/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx b/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
index 9256ade..4a6ade6 100644
--- a/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
+++ b/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
@@ -21,6 +21,7 @@ import {
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceRagInsightsPage.module.css';
+import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
const MIME_LABELS: Record = {
pdf: 'PDF',
@@ -35,18 +36,6 @@ const MIME_LABELS: Record = {
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
-function _formatBytes(n: number): string {
- if (!Number.isFinite(n) || n <= 0) return '0 B';
- const units = ['B', 'KB', 'MB', 'GB'];
- let v = n;
- let i = 0;
- while (v >= 1024 && i < units.length - 1) {
- v /= 1024;
- i += 1;
- }
- return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
-}
-
interface RagKpis {
indexedDocuments: number;
indexedBytesTotal: number;
@@ -161,7 +150,7 @@ export const WorkspaceRagInsightsPage: React.FC = () => {
Indexierte Dokumente
-
{_formatBytes(kpis.indexedBytesTotal)}
+
{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}
Indexiertes Datenvolumen (geschätzt)
diff --git a/src/utils/formatDataSize.ts b/src/utils/formatDataSize.ts
new file mode 100644
index 0000000..f0f6668
--- /dev/null
+++ b/src/utils/formatDataSize.ts
@@ -0,0 +1,43 @@
+/**
+ * Central binary (1024) data-size formatting for the UI.
+ *
+ * - Use formatBinaryDataSizeBytes for raw byte counts (files, RAG totals, …).
+ * - Use formatBinaryDataSizeFromMebibytes for API fields stored as MB (mebibytes), e.g. maxDataVolumeMB.
+ */
+
+const BINARY_BASE = 1024;
+const BINARY_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
+
+function _maxFractionDigits(value: number): number {
+ if (value >= 100 || Number.isInteger(value)) return 0;
+ if (value >= 10) return 1;
+ return 2;
+}
+
+/**
+ * Human-readable size from a byte count; picks B … TB automatically (1024-based).
+ */
+export function formatBinaryDataSizeBytes(bytes: number, localeId = 'de-CH'): string {
+ if (!Number.isFinite(bytes)) return '—';
+ if (bytes < 0) return '—';
+ if (bytes === 0) return `0 ${BINARY_UNITS[0]}`;
+
+ const rawExp = Math.floor(Math.log(bytes) / Math.log(BINARY_BASE));
+ const exp = Math.max(0, Math.min(BINARY_UNITS.length - 1, rawExp));
+ const value = bytes / BINARY_BASE ** exp;
+ const maxFrac = _maxFractionDigits(value);
+ const formatted = new Intl.NumberFormat(localeId, {
+ maximumFractionDigits: maxFrac,
+ minimumFractionDigits: 0,
+ }).format(value);
+ return `${formatted} ${BINARY_UNITS[exp]}`;
+}
+
+/**
+ * Same as formatBinaryDataSizeBytes, but input is mebibytes (API convention for plan limits).
+ */
+export function formatBinaryDataSizeFromMebibytes(mebibytes: number, localeId = 'de-CH'): string {
+ if (!Number.isFinite(mebibytes) || mebibytes < 0) return '—';
+ if (mebibytes === 0) return `0 ${BINARY_UNITS[0]}`;
+ return formatBinaryDataSizeBytes(mebibytes * BINARY_BASE * BINARY_BASE, localeId);
+}
From 317e019b180c1d0d09df9baeb07b4e870def7715 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 29 Mar 2026 21:55:13 +0200
Subject: [PATCH 09/11] 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 && (
-
- {
- 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'}
-
- {
- 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'}
-
-
- )}
{!renaming && (
-
- {sel.onRenameFile && !multiSelected && (
- { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
-
-
- )}
- {multiSelected && isSelected ? (
- <>
- {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
-
-
- {sel.selectedFolderIds.length}
-
- )}
- {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
-
-
- {sel.selectedFileIds.length}
-
- )}
- >
- ) : (
- (sel.onDeleteFile || sel.onDeleteFiles) && (
-
-
+
+
+ {sel.onRenameFile && !multiSelected && (
+ { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
+
- )
+ )}
+ {multiSelected && isSelected ? (
+ <>
+ {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
+
+
+ {sel.selectedFolderIds.length}
+
+ )}
+ {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
+
+
+ {sel.selectedFileIds.length}
+
+ )}
+ >
+ ) : (
+ (sel.onDeleteFile || sel.onDeleteFiles) && (
+
+
+
+ )
+ )}
+
+ {file.fileSize != null && (
+
+ {(file.fileSize / 1024).toFixed(0)}K
+
+ )}
+ {file.scope != null && (
+
+ {
+ 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'}
+
+ {
+ 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'}
+
+
)}
)}
@@ -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.
-
GDPR / Privacy Datenexport, Portabilitaet und Kontoloeschung.
-
GDPR oeffnen
+
GDPR / Privacy 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,
From 9d4e5bc90d8c00284a1e3d39754894946ac6248b Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 30 Mar 2026 00:15:01 +0200
Subject: [PATCH 10/11] streamlined neutralization flow
---
src/pages/Settings.tsx | 23 +-
.../views/workspace/NeutralizationPanel.tsx | 394 +++++++++++++++---
.../views/workspace/WorkspaceSettingsPage.tsx | 11 +-
3 files changed, 361 insertions(+), 67 deletions(-)
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 84691a2..bfe6de9 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -23,7 +23,7 @@ const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'profile', label: 'Profil' },
{ key: 'appearance', label: 'Darstellung' },
{ key: 'voice', label: 'Stimme & Sprache' },
- { key: 'neutralization', label: 'Datenneutralisierung' },
+ { key: 'neutralization', label: 'Neutralisierung (lokal)' },
{ key: 'privacy', label: 'Datenschutz' },
];
@@ -358,12 +358,27 @@ const NeutralizationMappingsTab: React.FC = () => {
{error && {error}
}
- Platzhalter-Mappings
+ Platzhalter-Mappings (lokal)
+
+ AI-Workspace: Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
+ Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“ (nicht auf dieser
+ Seite). Dieser Tab zeigt nur die lokale Liste über /api/local/neutralization-mappings.
+
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle
geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber
- den AI-Service). Diese Liste betrifft nur Ihre gespeicherten Platzhalter-Zuordnungen — hier einsehbar und
- loeschbar.
+ den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings — hier einsehbar und loeschbar.
{mappings.length === 0 ? (
diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx
index 22a5812..c8e1179 100644
--- a/src/pages/views/workspace/NeutralizationPanel.tsx
+++ b/src/pages/views/workspace/NeutralizationPanel.tsx
@@ -1,6 +1,9 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
import api from '../../../api';
+const _chatPromptSourceId = '__chat_prompt__';
+const _placeholderRx = /\[([a-z]+)\.([a-f0-9-]{36})\]/g;
+
interface NeutralizationMapping {
id: string;
originalText: string;
@@ -11,38 +14,220 @@ interface NeutralizationMapping {
createdAt?: string;
}
+interface NeutralizationSnapshot {
+ id: string;
+ sourceLabel: string;
+ neutralizedText: string;
+ placeholderCount: number;
+}
+
interface NeutralizationSource {
fileId: string;
fileName: string;
neutralizationStatus: string;
mappingCount: number;
+ isVirtual?: boolean;
}
interface NeutralizationPanelProps {
instanceId: string;
}
+function _normalizeApiRow(raw: Record): NeutralizationMapping {
+ const id = String(raw.id ?? '');
+ const patternType = String(raw.patternType ?? 'unknown');
+ const existingPh = raw.placeholder;
+ const placeholder =
+ typeof existingPh === 'string' && existingPh
+ ? existingPh
+ : id
+ ? `[${patternType}.${id}]`
+ : '';
+ return {
+ id,
+ originalText: String(raw.originalText ?? ''),
+ placeholder,
+ patternType,
+ fileId: raw.fileId != null && raw.fileId !== '' ? String(raw.fileId) : undefined,
+ fileName: raw.fileName != null ? String(raw.fileName) : undefined,
+ createdAt:
+ raw.createdAt != null
+ ? String(raw.createdAt)
+ : raw.sysCreatedAt != null
+ ? String(raw.sysCreatedAt)
+ : undefined,
+ };
+}
+
+function _partitionAttributes(rows: unknown[]): {
+ byFile: Record;
+ unscoped: NeutralizationMapping[];
+} {
+ const byFile: Record = {};
+ const unscoped: NeutralizationMapping[] = [];
+ for (const item of rows) {
+ if (!item || typeof item !== 'object') continue;
+ const raw = item as Record;
+ const m = _normalizeApiRow(raw);
+ const fid = raw.fileId;
+ if (fid == null || fid === '') {
+ unscoped.push(m);
+ } else {
+ const key = String(fid);
+ if (!byFile[key]) byFile[key] = [];
+ byFile[key].push(m);
+ }
+ }
+ return { byFile, unscoped };
+}
+
+const _phTypeColors: Record = {
+ name: '#7c3aed',
+ email: '#2563eb',
+ phone: '#0891b2',
+ address: '#059669',
+ financial: '#d97706',
+ id: '#dc2626',
+ logic: '#be185d',
+ company: '#4f46e5',
+ product: '#7c3aed',
+ location: '#059669',
+ other: '#6b7280',
+};
+
+function _renderHighlightedText(
+ text: string,
+ mappingLookup: Map,
+): React.ReactNode[] {
+ const parts: React.ReactNode[] = [];
+ let lastIdx = 0;
+ const rx = new RegExp(_placeholderRx.source, 'g');
+ let match: RegExpExecArray | null;
+
+ while ((match = rx.exec(text)) !== null) {
+ if (match.index > lastIdx) {
+ parts.push({text.slice(lastIdx, match.index)} );
+ }
+ const phType = match[1];
+ const phId = match[2];
+ const fullPh = match[0];
+ const mapping = mappingLookup.get(phId);
+ const color = _phTypeColors[phType] || _phTypeColors.other;
+ parts.push(
+
+ {fullPh}
+ ,
+ );
+ lastIdx = match.index + match[0].length;
+ }
+ if (lastIdx < text.length) {
+ parts.push({text.slice(lastIdx)} );
+ }
+ return parts;
+}
+
const NeutralizationPanel: React.FC = ({ instanceId }) => {
const [sources, setSources] = useState([]);
const [selectedSource, setSelectedSource] = useState(null);
const [mappings, setMappings] = useState([]);
const [loading, setLoading] = useState(true);
+ const [attributeByFile, setAttributeByFile] = useState>({});
+ const [attributeUnscoped, setAttributeUnscoped] = useState([]);
+ const [snapshots, setSnapshots] = useState([]);
+ const [expandedSnapshot, setExpandedSnapshot] = useState(null);
+
+ const _mappingLookup = useMemo(() => {
+ const map = new Map();
+ for (const m of attributeUnscoped) map.set(m.id, m);
+ for (const arr of Object.values(attributeByFile)) {
+ for (const m of arr) map.set(m.id, m);
+ }
+ return map;
+ }, [attributeUnscoped, attributeByFile]);
const _loadSources = useCallback(async () => {
setLoading(true);
try {
- const response = await api.get(`/api/workspace/${instanceId}/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,
- fileName: f.fileName || f.name || 'unknown',
- neutralizationStatus: f.neutralizationStatus || f.status || 'unknown',
- mappingCount: 0,
- }));
- setSources(neutralized);
+ const headers = instanceId ? { 'X-Instance-Id': instanceId } : undefined;
+ const [filesResponse, attrResponse] = await Promise.all([
+ api.get(`/api/workspace/${instanceId}/files`, { headers }),
+ api.get('/api/neutralization/attributes', { headers }),
+ ]);
+
+ let snapAxios: { data: unknown } = { data: [] };
+ try {
+ const _enc = encodeURIComponent(instanceId);
+ snapAxios = await api.get(`/api/neutralization/${_enc}/snapshots`, { headers });
+ } catch (_snapErr) {
+ console.warn('NeutralizationPanel: scoped snapshots failed, trying unscoped:', _snapErr);
+ try {
+ snapAxios = await api.get('/api/neutralization/snapshots', { headers });
+ } catch (_snapErr2) {
+ console.error('NeutralizationPanel: snapshots could not be loaded:', _snapErr2);
+ snapAxios = { data: [] };
+ }
+ }
+
+ const rawFiles = filesResponse.data;
+ const files = Array.isArray(rawFiles) ? rawFiles : (rawFiles?.files || rawFiles?.data || []);
+ const fileList = Array.isArray(files) ? files : [];
+
+ const attrPayload = attrResponse.data?.data ?? attrResponse.data ?? [];
+ const attrRows = Array.isArray(attrPayload) ? attrPayload : [];
+ const { byFile, unscoped } = _partitionAttributes(attrRows);
+ setAttributeByFile(byFile);
+ setAttributeUnscoped(unscoped);
+
+ const _snapBody = snapAxios.data as { data?: unknown } | unknown[] | null | undefined;
+ const snapPayload =
+ Array.isArray(_snapBody) ? _snapBody : (_snapBody && typeof _snapBody === 'object' && 'data' in _snapBody
+ ? ( _snapBody as { data: unknown }).data
+ : _snapBody) ?? [];
+ const snapList: NeutralizationSnapshot[] = Array.isArray(snapPayload) ? (snapPayload as NeutralizationSnapshot[]) : [];
+ setSnapshots(snapList);
+ if (snapList.length > 0 && snapList[0].id) {
+ setExpandedSnapshot(snapList[0].id);
+ } else {
+ setExpandedSnapshot(null);
+ }
+
+ const neutralizedFiles = fileList.filter((f: Record) => f.neutralize);
+
+ const nextSources: NeutralizationSource[] = [];
+ if (unscoped.length > 0) {
+ nextSources.push({
+ fileId: _chatPromptSourceId,
+ fileName: 'Chat, Prompt & Kontext',
+ neutralizationStatus: 'completed',
+ mappingCount: unscoped.length,
+ isVirtual: true,
+ });
+ }
+ for (const f of neutralizedFiles) {
+ const fid = String(f.id ?? '');
+ if (!fid) continue;
+ nextSources.push({
+ fileId: fid,
+ fileName: String(f.fileName ?? f.name ?? 'unknown'),
+ neutralizationStatus: String(f.neutralizationStatus ?? f.status ?? 'unknown'),
+ mappingCount: byFile[fid]?.length ?? 0,
+ });
+ }
+ setSources(nextSources);
} catch (err) {
console.error('Failed to load neutralization sources:', err);
} finally {
@@ -50,35 +235,28 @@ const NeutralizationPanel: React.FC = ({ instanceId })
}
}, [instanceId]);
- const _loadMappings = useCallback(async (fileId: string) => {
- try {
- const response = await api.get(`/api/neutralization/${instanceId}/attributes`, { params: { fileId } });
- const data = response.data?.data || response.data || [];
- setMappings(data.map((m: any) => ({
- id: m.id,
- originalText: m.originalText || '',
- placeholder: m.placeholder || m.id,
- patternType: m.patternType || 'unknown',
- fileId: m.fileId,
- fileName: m.fileName,
- createdAt: m.createdAt || m.sysCreatedAt,
- })));
- } catch (err) {
- console.error('Failed to load mappings:', err);
- setMappings([]);
- }
- }, [instanceId]);
-
- useEffect(() => { _loadSources(); }, [_loadSources]);
+ useEffect(() => {
+ _loadSources();
+ }, [_loadSources]);
useEffect(() => {
- if (selectedSource) _loadMappings(selectedSource);
- }, [selectedSource, _loadMappings]);
+ if (!selectedSource) {
+ setMappings([]);
+ return;
+ }
+ if (selectedSource === _chatPromptSourceId) {
+ setMappings(attributeUnscoped);
+ return;
+ }
+ setMappings(attributeByFile[selectedSource] ?? []);
+ }, [selectedSource, attributeByFile, attributeUnscoped]);
const _handleDeleteMapping = async (mappingId: string) => {
try {
- await api.delete(`/api/neutralization/${instanceId}/attributes/single/${mappingId}`);
- setMappings(prev => prev.filter(m => m.id !== mappingId));
+ await api.delete(`/api/neutralization/attributes/single/${mappingId}`, {
+ headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined,
+ });
+ await _loadSources();
} catch (err) {
console.error('Failed to delete mapping:', err);
}
@@ -86,8 +264,12 @@ const NeutralizationPanel: React.FC = ({ instanceId })
const _handleRetrigger = async (fileId: string) => {
try {
- await api.post(`/api/neutralization/${instanceId}/retrigger`, { fileId });
- _loadSources();
+ await api.post(
+ '/api/neutralization/retrigger',
+ { fileId },
+ { headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined },
+ );
+ await _loadSources();
} catch (err) {
console.error('Failed to retrigger neutralization:', err);
}
@@ -110,26 +292,82 @@ const NeutralizationPanel: React.FC = ({ instanceId })
if (loading) return Lade Neutralisierungsdaten...
;
+ const _hasAnyData = sources.length > 0 || snapshots.length > 0;
+
return (
Neutralisierung
- Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings.
+ Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original ↔ Platzhalter).
- {sources.length === 0 ? (
-
- Keine Datenquellen mit aktiver Neutralisierung.
-
- ) : (
+ {/* ── Snapshots: neutralisierter Text ──────────────────────── */}
+ {snapshots.length > 0 && (
+
+ Neutralisierter Text ({snapshots.length})
+
+ {snapshots.map((snap) => {
+ const _isExpanded = expandedSnapshot === snap.id;
+ return (
+
+
setExpandedSnapshot(_isExpanded ? null : snap.id)}
+ style={{
+ padding: '8px 12px',
+ background: 'var(--bg-hover, #f9fafb)',
+ cursor: 'pointer',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ fontSize: '0.85rem',
+ }}
+ >
+ {snap.sourceLabel}
+
+ {snap.placeholderCount} Platzhalter {_isExpanded ? '\u25BC' : '\u25B6'}
+
+
+ {_isExpanded && (
+
+ {_renderHighlightedText(snap.neutralizedText, _mappingLookup)}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */}
+ {sources.length > 0 && (
+
+
+ Datenquellen
+
{sources.map((src) => (
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)}
>
@@ -137,24 +375,38 @@ const NeutralizationPanel: React.FC
= ({ instanceId })
{src.fileName}
{_statusBadge(src.neutralizationStatus)}
+ {src.mappingCount > 0 && (
+ {src.mappingCount} Mapping(s)
+ )}
- { e.stopPropagation(); _handleRetrigger(src.fileId); }}
- style={{ fontSize: '0.8rem', padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-color, #d1d5db)', background: 'transparent', cursor: 'pointer' }}
- >
- Erneut neutralisieren
-
-
- {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
-
+ {!src.isVirtual && (
+ {
+ e.stopPropagation();
+ _handleRetrigger(src.fileId);
+ }}
+ style={{
+ fontSize: '0.8rem',
+ padding: '4px 10px',
+ borderRadius: 6,
+ border: '1px solid var(--border-color, #d1d5db)',
+ background: 'transparent',
+ cursor: 'pointer',
+ }}
+ >
+ Erneut neutralisieren
+
+ )}
+ {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
))}
)}
+ {/* ── Mappings für ausgewählte Quelle ──────────────────────── */}
{selectedSource && mappings.length > 0 && (
@@ -162,10 +414,22 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{mappings.map((m) => (
-
-
{m.placeholder}
+
+
{m.placeholder}
{'\u2192'}
-
{m.originalText}
+
+ {m.originalText}
+
{m.patternType}
_handleDeleteMapping(m.id)}
@@ -182,7 +446,15 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{selectedSource && mappings.length === 0 && (
- Keine Mappings für diese Datenquelle.
+ {selectedSource === _chatPromptSourceId
+ ? 'Keine Mappings ohne Dateizuordnung.'
+ : 'Keine gespeicherten Mappings für diese Datenquelle.'}
+
+ )}
+
+ {!_hasAnyData && (
+
+ Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung.
)}
diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx
index 8f25088..644f253 100644
--- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx
+++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx
@@ -14,7 +14,7 @@ type SettingsTab = 'general' | 'neutralization';
const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'general', label: 'Generelle Einstellungen' },
- { key: 'neutralization', label: 'Neutralisierung' },
+ { key: 'neutralization', label: 'Neutralisierung (Workspace)' },
];
export const WorkspaceSettingsPage: React.FC = () => {
@@ -67,7 +67,14 @@ export const WorkspaceSettingsPage: React.FC = () => {
)}
{activeTab === 'neutralization' && (
-
+ <>
+
+ Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser
+ Workspace-Instanz. (Die Benutzer-Einstellungen unter /settings → „Neutralisierung (lokal)“
+ ist eine andere Seite.)
+
+
+ >
)}
From 9ea6ed46132f8b52af581c235e43a881d2c2095e Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 30 Mar 2026 23:03:33 +0200
Subject: [PATCH 11/11] fixed onboarding flow
---
.../AutomationEditor/AutomationEditor.tsx | 19 +-
src/components/OnboardingAssistant.tsx | 89 ++++---
src/components/OnboardingWizard.tsx | 23 +-
.../ProviderSelector.module.css | 59 +++--
.../ProviderSelector/ProviderSelector.tsx | 233 +++++++++++-------
src/components/ProviderSelector/index.ts | 15 +-
src/hooks/usePrompt.tsx | 8 +-
src/pages/Login.tsx | 11 +-
src/pages/Register.tsx | 62 +----
.../admin/wizards/AdminMandateWizardPage.tsx | 1 +
src/pages/views/workspace/WorkspaceInput.tsx | 16 +-
src/pages/views/workspace/WorkspacePage.tsx | 13 +-
12 files changed, 308 insertions(+), 241 deletions(-)
diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx
index 56524d9..aa81bc0 100644
--- a/src/components/AutomationEditor/AutomationEditor.tsx
+++ b/src/components/AutomationEditor/AutomationEditor.tsx
@@ -14,7 +14,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
import { Popup } from '../UiComponents/Popup';
import { ActionsPanel } from '../ActionsPanel';
-import { ProviderMultiSelect } from '../ProviderSelector';
+import { ProviderMultiSelect, _defaultProviderSelection, _migrateFromLegacy, _toBackendProviders } from '../ProviderSelector';
+import type { ProviderSelection } from '../ProviderSelector';
+import { useBilling } from '../../hooks/useBilling';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useWorkflowActions } from '../../hooks/useAutomations';
@@ -374,7 +376,8 @@ export const AutomationEditor: React.FC = ({
const [label, setLabel] = useState('');
const [schedule, setSchedule] = useState('0 22 * * *');
const [active, setActive] = useState(false);
- const [allowedProviders, setAllowedProviders] = useState([]);
+ const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection());
+ const { allowedProviders: billingProviders } = useBilling();
// Template multilingual fields
const [labelMulti, setLabelMulti] = useState({ en: '', de: '' });
@@ -537,7 +540,7 @@ export const AutomationEditor: React.FC = ({
setLabel(def.label || '');
setSchedule(def.schedule || '0 22 * * *');
setActive(def.active ?? false);
- setAllowedProviders(def.allowedProviders || []);
+ setProviderSelection(_migrateFromLegacy(def.allowedProviders || []));
}
// Extract template JSON
@@ -693,7 +696,7 @@ export const AutomationEditor: React.FC = ({
active,
template: templateJson,
placeholders,
- allowedProviders
+ allowedProviders: _toBackendProviders(providerSelection, billingProviders),
};
}
@@ -709,7 +712,7 @@ export const AutomationEditor: React.FC = ({
} finally {
setIsSaving(false);
}
- }, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
+ }, [label, schedule, active, providerSelection, billingProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
// Computed values
const editorTitle = title || (mode === 'template'
@@ -864,12 +867,12 @@ export const AutomationEditor: React.FC = ({
{/* Allowed AI Providers */}
- Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt.
+ Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.
diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx
index 689d624..2ea90ec 100644
--- a/src/components/OnboardingAssistant.tsx
+++ b/src/components/OnboardingAssistant.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import api from '../api';
+import OnboardingWizard from './OnboardingWizard';
interface OnboardingStep {
id: string;
@@ -17,7 +18,7 @@ interface OnboardingAssistantProps {
const _STORAGE_KEY = 'onboarding_hidden';
const _CALLOUTS: Record = {
- mandate: 'Tipp: Ein Mandant ist Ihr persoenlicher Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.',
+ mandate: 'Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.',
feature: 'Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.',
connection: 'Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.',
chat: 'Tipp: Starten Sie einen Chat mit dem AI-Assistenten. Er kann Ihre verbundenen Daten analysieren und Fragen beantworten.',
@@ -50,46 +51,59 @@ const OnboardingAssistant: React.FC = ({ onDismiss })
const [steps, setSteps] = useState([]);
const [loading, setLoading] = useState(true);
const [dontShowAgain, setDontShowAgain] = useState(false);
+ const [showWizard, setShowWizard] = useState(false);
const _checkOnboardingState = useCallback(async () => {
setLoading(true);
try {
const onboardingSteps: OnboardingStep[] = [];
- let hasMandate = false;
+ // Check admin mandates (user-owned or where user is admin)
+ let hasAdminMandate = false;
try {
const mandatesRes = await api.get('/api/store/mandates');
const mandates = mandatesRes.data?.mandates || mandatesRes.data || [];
- hasMandate = Array.isArray(mandates) && mandates.length > 0;
+ hasAdminMandate = Array.isArray(mandates) && mandates.length > 0;
} catch { /* ignore */ }
- onboardingSteps.push({
- id: 'mandate',
- label: 'Mandant einrichten',
- description: hasMandate
- ? 'Dein Mandant ist eingerichtet.'
- : 'Richte deinen ersten Mandanten ein.',
- completed: hasMandate,
- action: hasMandate ? undefined : () => navigate('/store'),
- });
-
+ // Check if user has any feature access (via navigation = mandate member)
let hasFeature = false;
- let firstInstancePath: string | undefined;
+ let workspaceInstancePath: string | undefined;
+ let workspaceInstanceIds: string[] = [];
try {
const navRes = await api.get('/api/navigation?language=de');
- const mandates = navRes.data?.mandates || [];
+ const blocks = navRes.data?.blocks || [];
+ const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
+ const mandates = dynamicBlock?.mandates || [];
for (const m of mandates) {
for (const f of m.features || []) {
for (const inst of f.instances || []) {
- if (!hasFeature) hasFeature = true;
- if (!firstInstancePath && inst.views?.length > 0) {
- firstInstancePath = inst.views[0].uiPath;
+ hasFeature = true;
+ if (f.uiComponent === 'feature.workspace' && inst.views?.length > 0) {
+ workspaceInstanceIds.push(inst.id);
+ if (!workspaceInstancePath) {
+ workspaceInstancePath = inst.views[0].uiPath;
+ }
}
}
}
}
} catch { /* ignore */ }
+ const mandateStepDone = hasAdminMandate || hasFeature;
+
+ onboardingSteps.push({
+ id: 'mandate',
+ label: 'Mandant einrichten',
+ description: hasAdminMandate
+ ? 'Dein Mandant ist eingerichtet.'
+ : hasFeature
+ ? 'Du bist Mitglied eines Mandanten.'
+ : 'Erstelle deinen Arbeitsbereich.',
+ completed: mandateStepDone,
+ action: mandateStepDone ? undefined : () => setShowWizard(true),
+ });
+
onboardingSteps.push({
id: 'feature',
label: 'Erstes Feature aktivieren',
@@ -103,8 +117,8 @@ const OnboardingAssistant: React.FC = ({ onDismiss })
let hasConnection = false;
try {
const connRes = await api.get('/api/connections/');
- const connections = connRes.data?.data || connRes.data || [];
- hasConnection = Array.isArray(connections) && connections.length > 0;
+ const items = connRes.data?.items || connRes.data?.data || connRes.data || [];
+ hasConnection = Array.isArray(items) && items.length > 0;
} catch { /* ignore */ }
onboardingSteps.push({
@@ -118,25 +132,16 @@ const OnboardingAssistant: React.FC = ({ onDismiss })
});
let hasChat = false;
- if (hasFeature && firstInstancePath) {
+ for (const instId of workspaceInstanceIds) {
+ if (hasChat) break;
try {
- const featuresRes = await api.get('/api/store/features');
- const features = featuresRes.data || [];
- for (const f of features) {
- if (hasChat) break;
- for (const inst of f.instances || []) {
- if (hasChat) break;
- try {
- const wfRes = await api.get(`/api/workspace/${inst.id}/workflows`);
- const wfs = wfRes.data?.workflows || wfRes.data?.data || [];
- if (Array.isArray(wfs) && wfs.length > 0) hasChat = true;
- } catch { /* ignore */ }
- }
- }
+ const wfRes = await api.get(`/api/workspace/${instId}/workflows`);
+ const wfs = wfRes.data?.workflows || wfRes.data?.data || wfRes.data?.items || [];
+ if (Array.isArray(wfs) && wfs.length > 0) hasChat = true;
} catch { /* ignore */ }
}
- const _chatAction = firstInstancePath ? () => navigate(firstInstancePath!) : undefined;
+ const chatAction = workspaceInstancePath ? () => navigate(workspaceInstancePath!) : undefined;
onboardingSteps.push({
id: 'chat',
label: 'Ersten AI-Chat starten',
@@ -144,7 +149,7 @@ const OnboardingAssistant: React.FC = ({ onDismiss })
? 'Du hast bereits Chats gestartet.'
: 'Starte deinen ersten Chat mit dem AI-Assistenten.',
completed: hasChat,
- action: hasChat ? undefined : _chatAction,
+ action: hasChat ? undefined : chatAction,
});
setSteps(onboardingSteps);
@@ -180,6 +185,18 @@ const OnboardingAssistant: React.FC = ({ onDismiss })
onDismiss?.();
};
+ if (showWizard) {
+ return (
+ {
+ setShowWizard(false);
+ _checkOnboardingState();
+ }}
+ onDismiss={() => setShowWizard(false)}
+ />
+ );
+ }
+
if (hidden || loading) return null;
const completedCount = steps.filter(s => s.completed).length;
diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx
index a1e9fa4..ae8f4c1 100644
--- a/src/components/OnboardingWizard.tsx
+++ b/src/components/OnboardingWizard.tsx
@@ -8,7 +8,7 @@ interface OnboardingWizardProps {
const OnboardingWizard: React.FC = ({ onComplete, onDismiss }) => {
const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D');
- const [companyName, setCompanyName] = useState('');
+ const [mandateName, setMandateName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@@ -16,10 +16,15 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi
setLoading(true);
setError(null);
try {
- await api.post('/api/local/onboarding', {
+ const res = await api.post('/api/local/onboarding', {
planKey,
- companyName: companyName.trim() || undefined,
+ companyName: mandateName.trim() || undefined,
});
+ if (res.data?.alreadyProvisioned) {
+ setError('Du hast bereits einen Mandanten mit Admin-Zugang.');
+ return;
+ }
+ window.dispatchEvent(new CustomEvent('features-changed'));
onComplete();
} catch (err: any) {
setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung');
@@ -38,9 +43,9 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi
background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px',
maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}>
- Willkommen bei PowerOn
+ Mandant erstellen
- Wähle dein Abo und leg los.
+ Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.
@@ -80,8 +85,8 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi
Name des Mandanten (optional)
setCompanyName(e.target.value)}
+ type="text" value={mandateName}
+ onChange={(e) => setMandateName(e.target.value)}
placeholder="z. B. Firmenname oder Projektname"
style={{
width: '100%', padding: '10px 12px', borderRadius: '6px',
@@ -98,7 +103,7 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi
padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)',
background: 'transparent', cursor: 'pointer',
}}>
- Später
+ Abbrechen
= ({ onComplete, onDismi
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
opacity: loading ? 0.6 : 1,
}}>
- {loading ? 'Wird eingerichtet...' : 'Loslegen'}
+ {loading ? 'Wird eingerichtet...' : 'Mandant erstellen'}
diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css
index 04d2be7..384c1c5 100644
--- a/src/components/ProviderSelector/ProviderSelector.module.css
+++ b/src/components/ProviderSelector/ProviderSelector.module.css
@@ -53,17 +53,17 @@
justify-content: center;
width: 36px;
height: 36px;
- border: 1px solid var(--border-color, #3a3a3a);
+ border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
- background: var(--surface-color, #2d2d2d);
- color: var(--text-secondary, #888);
+ background: var(--surface-color, #ffffff);
+ color: var(--text-secondary, #666666);
cursor: pointer;
transition: all 0.2s;
}
.triggerButton:hover:not(:disabled) {
- background: var(--bg-secondary, #3a3a3a);
- color: var(--text-primary, #fff);
+ background: var(--hover-bg, rgba(0, 0, 0, 0.06));
+ color: var(--text-primary, #1a1a1a);
}
.triggerButton:disabled {
@@ -83,20 +83,20 @@
transform: translateX(-50%);
z-index: 1000;
padding: 8px;
- background: var(--surface-color, #2d2d2d);
- border: 1px solid var(--border-color, #3a3a3a);
+ background: var(--surface-color, #ffffff);
+ border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
- box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5);
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
min-width: 220px;
}
.dropdownHeader {
font-size: 0.75rem;
font-weight: 500;
- color: var(--text-secondary, #888);
+ color: var(--text-secondary, #666666);
padding: 4px 8px;
margin-bottom: 4px;
- border-bottom: 1px solid var(--border-color, #3a3a3a);
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.selectActions {
@@ -108,18 +108,18 @@
.actionButton {
flex: 1;
padding: 4px 8px;
- border: 1px solid var(--border-color, #3a3a3a);
+ border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
- background: var(--bg-secondary, #252525);
- color: var(--text-secondary, #888);
+ background: var(--bg-secondary, #f8f9fa);
+ color: var(--text-secondary, #666666);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.actionButton:hover:not(:disabled) {
- background: var(--bg-hover, #3a3a3a);
- color: var(--text-primary, #fff);
+ background: var(--hover-bg, rgba(0, 0, 0, 0.06));
+ color: var(--text-primary, #1a1a1a);
}
.actionButton.active {
@@ -138,7 +138,7 @@
flex-direction: column;
gap: 2px;
padding: 4px;
- background: var(--bg-secondary, #252525);
+ background: var(--bg-secondary, #f8f9fa);
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
@@ -151,12 +151,13 @@
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
- transition: background 0.15s ease;
- color: var(--text-primary, #e0e0e0);
+ transition: background 0.15s ease, color 0.15s ease;
+ color: var(--text-primary, #1a1a1a);
}
.checkboxItem:hover {
- background: var(--bg-hover, #3a3a3a);
+ background: var(--hover-bg, rgba(0, 0, 0, 0.06));
+ color: var(--text-primary, #1a1a1a);
}
.checkboxItem.disabled {
@@ -177,12 +178,12 @@
.providerName {
font-size: 0.8rem;
- color: var(--text-primary, #e0e0e0);
+ color: inherit;
}
.hint {
font-size: 0.7rem;
- color: var(--text-tertiary, #666);
+ color: var(--text-tertiary, #888888);
text-align: center;
padding: 4px 0;
}
@@ -192,10 +193,24 @@
align-items: center;
justify-content: center;
padding: 12px;
- color: var(--text-secondary, #888);
+ color: var(--text-secondary, #666666);
font-size: 0.8rem;
}
+/* Dark theme: list hover stays a light lift, not a black wash */
+:global(.dark-theme) .checkboxItem:hover {
+ background: var(--hover-bg, rgba(255, 255, 255, 0.08));
+ color: var(--text-primary, #e5e7eb);
+}
+
+:global(.dark-theme) .checkboxItem {
+ color: var(--text-primary, #e5e7eb);
+}
+
+:global(.dark-theme) .dropdownContent {
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
+}
+
/* ============================================================================
PROVIDER BADGES
============================================================================ */
diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx
index e41cefb..24cfebc 100644
--- a/src/components/ProviderSelector/ProviderSelector.tsx
+++ b/src/components/ProviderSelector/ProviderSelector.tsx
@@ -1,19 +1,80 @@
/**
* ProviderSelector Component
- *
+ *
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im AI Workspace und Automation Editor verwendet werden.
- *
- * Features:
- * - Dropdown für Einzelauswahl
- * - Checkbox-Liste für Mehrfachauswahl
- * - Lädt verfügbare Provider aus dem Billing-System
+ *
+ * Selektionsmodell:
+ * ProviderSelection { include: string[], exclude: string[] }
+ * - include(["ALL"]), exclude([]) → alle verfügbaren Provider (dynamisch)
+ * - include(["ALL"]), exclude(["private"]) → alle ausser "private" (dynamisch)
+ * - include(["anthropic"]), exclude([]) → nur Anthropic
+ * - include([]), exclude([]) → keiner ausgewählt
+ *
+ * resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
*/
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css';
+// ============================================================================
+// TYPES & HELPERS
+// ============================================================================
+
+export const PROVIDER_ALL = 'ALL';
+
+export interface ProviderSelection {
+ include: string[];
+ exclude: string[];
+}
+
+export function _defaultProviderSelection(): ProviderSelection {
+ return { include: [PROVIDER_ALL], exclude: [] };
+}
+
+export function _resolveProviders(
+ selection: ProviderSelection,
+ allowedProviders: string[],
+): string[] {
+ if (selection.include.includes(PROVIDER_ALL)) {
+ return allowedProviders.filter((p) => !selection.exclude.includes(p));
+ }
+ return selection.include.filter((p) => allowedProviders.includes(p));
+}
+
+export function _isAllSelected(selection: ProviderSelection): boolean {
+ return selection.include.includes(PROVIDER_ALL) && selection.exclude.length === 0;
+}
+
+export function _isNoneSelected(
+ selection: ProviderSelection,
+ allowedProviders: string[],
+): boolean {
+ return _resolveProviders(selection, allowedProviders).length === 0;
+}
+
+/**
+ * Migrate legacy string[] (old model) to ProviderSelection.
+ * [] → ALL, [...ids] → include those ids.
+ */
+export function _migrateFromLegacy(providers: string[]): ProviderSelection {
+ if (providers.length === 0) return _defaultProviderSelection();
+ return { include: providers, exclude: [] };
+}
+
+/**
+ * Convert ProviderSelection to flat list for backend API calls.
+ * Returns [] when ALL are selected (= no restriction / legacy behaviour).
+ */
+export function _toBackendProviders(
+ selection: ProviderSelection,
+ allowedProviders: string[],
+): string[] {
+ if (_isAllSelected(selection)) return [];
+ return _resolveProviders(selection, allowedProviders);
+}
+
// Provider display names
const PROVIDER_LABELS: Record = {
anthropic: 'Anthropic (Claude)',
@@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record = {
internal: 'Internal',
};
-// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record = {
anthropic: '🤖',
openai: '💬',
@@ -58,20 +118,20 @@ export const ProviderSelect: React.FC = ({
showLabel = true,
}) => {
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
-
+
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
-
+
const providerOptions = useMemo(() => {
return allowedProviders.map((provider) => ({
value: provider,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
}));
}, [allowedProviders]);
-
+
return (
{showLabel &&
{label} }
@@ -93,12 +153,12 @@ export const ProviderSelect: React.FC
= ({
};
// ============================================================================
-// MULTI SELECT COMPONENT (Checkbox List)
+// MULTI SELECT COMPONENT (Checkbox List) — include / exclude model
// ============================================================================
interface ProviderMultiSelectProps {
- selectedProviders: string[];
- onChange: (providers: string[]) => void;
+ selection: ProviderSelection;
+ onChange: (selection: ProviderSelection) => void;
disabled?: boolean;
className?: string;
label?: string;
@@ -108,7 +168,7 @@ interface ProviderMultiSelectProps {
}
export const ProviderMultiSelect: React.FC = ({
- selectedProviders,
+ selection,
onChange,
disabled = false,
className,
@@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC = ({
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
-
+
useEffect(() => {
if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders();
}
}, []);
-
- // Apply default exclusions when providers first load
+
+ // Apply default exclusions once when providers first load
useEffect(() => {
if (
!initialExcludeApplied &&
allowedProviders.length > 0 &&
excludeByDefault.length > 0 &&
- selectedProviders.length === 0
+ _isAllSelected(selection)
) {
- const initialSelection = allowedProviders.filter(
- (p) => !excludeByDefault.includes(p)
- );
- // Only apply if there's actually something to exclude
- if (initialSelection.length < allowedProviders.length) {
- onChange(initialSelection);
- }
+ onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] });
setInitialExcludeApplied(true);
}
- }, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]);
-
- // Click outside handler
- const handleClickOutside = useCallback((event: MouseEvent) => {
+ }, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
+
+ const _handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
}, []);
-
+
useEffect(() => {
if (isExpanded) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
+ document.addEventListener('mousedown', _handleClickOutside);
+ return () => document.removeEventListener('mousedown', _handleClickOutside);
}
- }, [isExpanded, handleClickOutside]);
-
- // Effective selection: empty array = all providers active (no restriction)
- const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
-
- // "Alle" is active when no restriction is set (empty array) OR all explicitly selected
- const isAllSelected = selectedProviders.length === 0 ||
- (allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
-
- const handleToggle = (provider: string) => {
- if (selectedProviders.length === 0) {
- // Currently "all active" (no restriction) -> make explicit: all except the toggled one
- onChange(allowedProviders.filter((p) => p !== provider));
- } else if (selectedProviders.includes(provider)) {
- // Deactivate: remove from selection
- const remaining = selectedProviders.filter((p) => p !== provider);
- // If removing leaves all others selected, reset to [] (= all, no restriction)
- if (remaining.length === allowedProviders.length) {
- onChange([]);
+ }, [isExpanded, _handleClickOutside]);
+
+ const effectiveSelection = useMemo(
+ () => _resolveProviders(selection, allowedProviders),
+ [selection, allowedProviders],
+ );
+
+ const allSelected = _isAllSelected(selection);
+ const noneSelected = effectiveSelection.length === 0;
+
+ const _handleToggle = (provider: string) => {
+ const isChecked = effectiveSelection.includes(provider);
+
+ if (selection.include.includes(PROVIDER_ALL)) {
+ // Currently ALL-based: toggle modifies exclude list
+ if (isChecked) {
+ onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] });
} else {
- onChange(remaining);
+ const nextExclude = selection.exclude.filter((p) => p !== provider);
+ onChange({ include: [PROVIDER_ALL], exclude: nextExclude });
}
} else {
- // Activate: add to selection
- const updated = [...selectedProviders, provider];
- // If all are now selected, reset to [] (= all, no restriction)
- if (updated.length === allowedProviders.length) {
- onChange([]);
+ // Explicit include list
+ if (isChecked) {
+ onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] });
} else {
- onChange(updated);
+ const nextInclude = [...selection.include, provider];
+ if (nextInclude.length === allowedProviders.length) {
+ onChange({ include: [PROVIDER_ALL], exclude: [] });
+ } else {
+ onChange({ include: nextInclude, exclude: [] });
+ }
}
}
};
-
- const handleSelectAll = () => {
- onChange([]); // Empty = all active, no restriction
+
+ const _handleSelectAll = () => {
+ onChange({ include: [PROVIDER_ALL], exclude: [] });
};
-
- // Summary icon for button
+
const summaryIcon = useMemo(() => {
+ if (noneSelected) return '⊘';
if (effectiveSelection.length === 1) {
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
}
- return '🤖';
- }, [effectiveSelection]);
-
+ return '⚡';
+ }, [effectiveSelection, noneSelected]);
+
+ const summaryHint = useMemo(() => {
+ if (noneSelected) return 'Kein Provider ausgewählt';
+ if (allSelected) return 'Alle Provider aktiv (dynamisch)';
+ if (selection.include.includes(PROVIDER_ALL)) {
+ return `Alle ausser ${selection.exclude.length} Provider`;
+ }
+ return `${effectiveSelection.length} von ${allowedProviders.length} Provider`;
+ }, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]);
+
return (
-
- {/* Trigger Button - styled like iconButton */}
-
setIsExpanded(!isExpanded)}
@@ -220,36 +283,35 @@ export const ProviderMultiSelect: React.FC = ({
>
{summaryIcon}
-
- {/* Dropdown Content */}
+
{isExpanded && (
{showLabel &&
{label}
}
-
+
-
Alle
-
+
{loading ? (
Lade...
) : (
)}
-
- {isAllSelected && !loading && (
-
- Alle Provider aktiv (kein Filter)
-
- )}
+
+
{summaryHint}
)}
@@ -288,7 +346,7 @@ export const ProviderBadges: React.FC = ({
if (providers.length === 0) {
return Alle Provider ;
}
-
+
return (
{providers.map((provider) => (
@@ -300,5 +358,4 @@ export const ProviderBadges: React.FC
= ({
);
};
-// Default export
export default ProviderSelect;
diff --git a/src/components/ProviderSelector/index.ts b/src/components/ProviderSelector/index.ts
index afe1b42..a2f6c79 100644
--- a/src/components/ProviderSelector/index.ts
+++ b/src/components/ProviderSelector/index.ts
@@ -5,6 +5,19 @@
export {
ProviderSelect,
ProviderMultiSelect,
- ProviderBadges
+ ProviderBadges,
} from './ProviderSelector';
+
+export {
+ PROVIDER_ALL,
+ _defaultProviderSelection,
+ _resolveProviders,
+ _isAllSelected,
+ _isNoneSelected,
+ _migrateFromLegacy,
+ _toBackendProviders,
+} from './ProviderSelector';
+
+export type { ProviderSelection } from './ProviderSelector';
+
export { default } from './ProviderSelector';
diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx
index b3117a2..c0c8837 100644
--- a/src/hooks/usePrompt.tsx
+++ b/src/hooks/usePrompt.tsx
@@ -82,7 +82,7 @@ export function usePrompt() {
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface-color, #1a1a2e)',
- border: '1px solid var(--color-border, #333)',
+ border: '1px solid var(--border-color, var(--color-border, #333))',
borderRadius: '12px',
padding: '1.5rem',
minWidth: 360, maxWidth: 500,
@@ -116,9 +116,9 @@ export function usePrompt() {
style={{
padding: '10px 14px',
borderRadius: '8px',
- border: '1px solid var(--color-border, #444)',
- background: 'var(--input-bg, #0d0d1a)',
- color: 'var(--text-primary, #e0e0e0)',
+ border: '1px solid var(--border-color, var(--color-border, #ccc))',
+ background: 'var(--input-bg, var(--bg-primary, #ffffff))',
+ color: 'var(--text-primary, #1a1a1a)',
fontSize: '0.9rem',
outline: 'none',
width: '100%',
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index 6fa20a7..4ac295f 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -240,16 +240,9 @@ function Login() {
navigate('/register?type=personal', { state: location.state })}
+ onClick={() => navigate('/register', { state: location.state })}
>
- Kostenlos testen
-
- navigate('/register?type=company', { state: location.state })}
- >
- Für Unternehmen
+ Kostenlos registrieren
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx
index 95051cd..8060b2c 100644
--- a/src/pages/Register.tsx
+++ b/src/pages/Register.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
-import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
+import { useNavigate, useLocation } from 'react-router-dom';
import { FaEnvelopeOpenText } from 'react-icons/fa';
import styles from './Register.module.css';
@@ -19,7 +19,6 @@ function Register() {
const { register, error: registerError, isLoading } = useRegister();
const { error: msalError } = useMsalRegister();
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
- // Pre-fill from invitation if provided via location.state
const invitationUsername = (location.state as any)?.invitationUsername || '';
const invitationEmail = (location.state as any)?.invitationEmail || '';
const [formData, setFormData] = useState({
@@ -27,10 +26,6 @@ function Register() {
email: invitationEmail,
fullName: ''
});
- const [searchParams] = useSearchParams();
- const registrationType = searchParams.get('type') === 'company' ? 'company' : 'personal';
- const [companyName, setCompanyName] = useState('');
- const [companyNameFocused, setCompanyNameFocused] = useState(false);
const [validationError, setValidationError] = useState(null);
const [successMessage, setSuccessMessage] = useState(null);
const [usernameFocused, setUsernameFocused] = useState(false);
@@ -38,19 +33,13 @@ function Register() {
const [fullNameFocused, setFullNameFocused] = useState(false);
const [usernameHighlight, setUsernameHighlight] = useState(false);
- // Check for pending invitation
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken;
- // Set page title and generate CSRF token
useEffect(() => {
- document.title = registrationType === 'company'
- ? "PowerOn AI Platform - Unternehmenskonto erstellen"
- : "PowerOn AI Platform - Kostenlos testen";
-
- // Generate CSRF token for new security implementation
+ document.title = "PowerOn AI Platform - Registrieren";
generateAndStoreCSRFToken();
- }, [registrationType]);
+ }, []);
const handleInputChange = (e: React.ChangeEvent) => {
const { name, value } = e.target;
@@ -59,13 +48,12 @@ function Register() {
[name]: value
}));
setValidationError(null);
- // Reset username highlight when user starts typing in username field
if (name === 'username') {
setUsernameHighlight(false);
}
};
- const validateForm = (): boolean => {
+ const _validateForm = (): boolean => {
if (!formData.username || !formData.email || !formData.fullName) {
setValidationError('Bitte füllen Sie alle Pflichtfelder aus.');
return false;
@@ -76,27 +64,20 @@ function Register() {
return false;
}
- if (registrationType === 'company' && !companyName.trim()) {
- setValidationError('Bitte geben Sie einen Firmennamen ein.');
- return false;
- }
-
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (!validateForm()) {
+ if (!_validateForm()) {
return;
}
try {
- // First check username availability
const availabilityResult = await checkAvailability(formData.username, 'local');
if (!availabilityResult.available) {
- // Check if the error message is about username being taken
const errorMessage = availabilityResult.message || 'Username is not available';
if (errorMessage === 'Username is already taken') {
setValidationError('Benutzername ist bereits vergeben');
@@ -107,25 +88,20 @@ function Register() {
return;
}
- // Username is available, proceed with registration (no password - magic link flow)
- await register({ ...formData, registrationType, companyName: registrationType === 'company' ? companyName : undefined });
+ await register({ ...formData, registrationType: 'personal' });
- // Build success message
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
if (hasPendingInvitation) {
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
}
- // Show success message instead of immediate redirect
setSuccessMessage(message);
- // Redirect to login page after delay
setTimeout(() => {
navigate('/login', {
state: {
registered: true,
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
- // Pass along invitation state
...(location.state || {})
}
});
@@ -135,8 +111,7 @@ function Register() {
}
};
- // Helper function to safely get error message
- const getErrorMessage = () => {
+ const _getErrorMessage = () => {
if (validationError) return validationError;
if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed';
if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed';
@@ -157,7 +132,6 @@ function Register() {
- {/* Pending invitation notice */}
{hasPendingInvitation && !successMessage && (
@@ -165,8 +139,8 @@ function Register() {
)}
- {getErrorMessage() && (
-
{getErrorMessage()}
+ {_getErrorMessage() && (
+
{_getErrorMessage()}
)}
{successMessage && (
@@ -203,22 +177,6 @@ function Register() {
E-Mail
- {registrationType === 'company' && (
-
- setCompanyName(e.target.value)}
- onFocus={() => setCompanyNameFocused(true)}
- onBlur={() => setCompanyNameFocused(false)}
- className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`}
- />
- Firmenname *
-
- )}
-
- {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : registrationType === 'company' ? 'Unternehmenskonto erstellen' : 'Kostenlos testen'}
+ {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'}
>
)}
diff --git a/src/pages/admin/wizards/AdminMandateWizardPage.tsx b/src/pages/admin/wizards/AdminMandateWizardPage.tsx
index 43814f8..1597127 100644
--- a/src/pages/admin/wizards/AdminMandateWizardPage.tsx
+++ b/src/pages/admin/wizards/AdminMandateWizardPage.tsx
@@ -236,6 +236,7 @@ export const AdminMandateWizardPage: React.FC = () => {
if (billingSaved) {
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
}
+ window.dispatchEvent(new CustomEvent('features-changed'));
await loadMandates();
} catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } }; message?: string };
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx
index 9bc03a8..7138661 100644
--- a/src/pages/views/workspace/WorkspaceInput.tsx
+++ b/src/pages/views/workspace/WorkspaceInput.tsx
@@ -5,6 +5,7 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
+import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
@@ -48,8 +49,8 @@ interface WorkspaceInputProps {
onRemovePendingFile?: (fileId: string) => void;
onFileUploadClick?: () => void;
uploading?: boolean;
- selectedProviders?: string[];
- onProvidersChange?: (providers: string[]) => void;
+ providerSelection?: ProviderSelection;
+ onProviderSelectionChange?: (selection: ProviderSelection) => void;
isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onPasteAsFile?: (file: File) => void;
@@ -69,8 +70,8 @@ export const WorkspaceInput: React.FC = ({
onRemovePendingFile,
onFileUploadClick,
uploading = false,
- selectedProviders = [],
- onProvidersChange,
+ providerSelection,
+ onProviderSelectionChange,
isMobile = false,
onTreeItemsDrop,
onPasteAsFile,
@@ -653,12 +654,11 @@ export const WorkspaceInput: React.FC = ({
)}
- {onProvidersChange && (
+ {onProviderSelectionChange && providerSelection && (
)}
diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx
index 01f965b..d333bd5 100644
--- a/src/pages/views/workspace/WorkspacePage.tsx
+++ b/src/pages/views/workspace/WorkspacePage.tsx
@@ -19,6 +19,9 @@ import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import api from '../../../api';
+import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
+import type { ProviderSelection } from '../../../components/ProviderSelector';
+import { useBilling } from '../../../hooks/useBilling';
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
const [width, setWidth] = useState(initialWidth);
@@ -81,7 +84,8 @@ export const WorkspacePage: React.FC
= ({ persistentInstance
const [udbTab, setUdbTab] = useState('chats');
const [selectedFileId, setSelectedFileId] = useState(null);
const [pendingFiles, setPendingFiles] = useState([]);
- const [selectedProviders, setSelectedProviders] = useState([]);
+ const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection());
+ const { allowedProviders } = useBilling();
const [isDragOver, setIsDragOver] = useState(false);
const [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0);
@@ -414,7 +418,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance
instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
- workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds, options);
+ const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
+ workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options);
setPendingFiles([]);
}}
isProcessing={workspace.isProcessing}
@@ -426,8 +431,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance
onRemovePendingFile={_handleRemovePendingFile}
onFileUploadClick={() => fileInputRef.current?.click()}
uploading={fileOps.uploadingFile}
- selectedProviders={selectedProviders}
- onProvidersChange={setSelectedProviders}
+ providerSelection={providerSelection}
+ onProviderSelectionChange={setProviderSelection}
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onPasteAsFile={_uploadAndAttach}