= ({
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/23] 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/23] 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/23] 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/23] 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}
From 71bf6baae5961bba2f243e3074a1ddf8e2d1a301 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 30 Mar 2026 23:39:13 +0200
Subject: [PATCH 12/23] fixes
---
.../nodes/shared/clickupFormSync.ts | 19 ++++++++++++++-----
src/components/FolderTree/FolderTree.tsx | 4 ++--
src/hooks/usePrompt.tsx | 2 +-
3 files changed, 17 insertions(+), 8 deletions(-)
diff --git a/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts b/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts
index 48faf0a..41cc91c 100644
--- a/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts
+++ b/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts
@@ -225,12 +225,20 @@ export function buildSyncFromClickUpList(args: {
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
];
+ const statusTriggerRow: TriggerFormFieldRow | null =
+ statusOpts.length > 0
+ ? {
+ name: PAYLOAD_STATUS,
+ label: 'Status',
+ type: 'clickup_status',
+ statusOptions: statusOpts,
+ }
+ : null;
+
const standardTrigger: TriggerFormFieldRow[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
- ...(statusOpts.length > 0
- ? [{ name: PAYLOAD_STATUS, label: 'Status', type: 'clickup_status', statusOptions: statusOpts }]
- : []),
+ ...(statusTriggerRow ? [statusTriggerRow] : []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
@@ -247,8 +255,9 @@ export function buildSyncFromClickUpList(args: {
if (inf) customInput.push(inf);
if (tr) customTrigger.push(tr);
const fid = String((f as ClickUpFieldLike).id ?? '');
- if (fid && inf) {
- customRefs[fid] = createRef(formNodeId, ['payload', inf.name]);
+ const payloadKey = inf?.name;
+ if (fid && payloadKey) {
+ customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]);
}
}
diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx
index d4f92ef..3712664 100644
--- a/src/components/FolderTree/FolderTree.tsx
+++ b/src/components/FolderTree/FolderTree.tsx
@@ -13,7 +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 { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
import styles from './FolderTree.module.css';
/* ── Public types ──────────────────────────────────────────────────────── */
@@ -331,7 +331,7 @@ interface TreeNodeProps {
showFiles: boolean;
filesByFolder: Map;
sel: SelectionCtx;
- promptFolderName: (message: string) => Promise;
+ promptFolderName: (message: string, options?: PromptOptions) => Promise;
onToggle: (id: string) => void;
onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise;
diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx
index c0c8837..38218d6 100644
--- a/src/hooks/usePrompt.tsx
+++ b/src/hooks/usePrompt.tsx
@@ -8,7 +8,7 @@
* // Render once in the component tree.
*/
-import React, { useState, useCallback, useRef, useEffect } from 'react';
+import React, { useState, useCallback, useRef } from 'react';
export interface PromptOptions {
title?: string;
From 624797246e578c6ef980347effbfbbee9ba36388 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 00:06:33 +0200
Subject: [PATCH 13/23] fixed reloads
---
src/hooks/useSubscription.ts | 8 +++++---
src/pages/billing/BillingAdmin.tsx | 5 +++--
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/hooks/useSubscription.ts b/src/hooks/useSubscription.ts
index ea98e6c..8fb6e1a 100644
--- a/src/hooks/useSubscription.ts
+++ b/src/hooks/useSubscription.ts
@@ -42,10 +42,11 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
const [plan, setPlan] = useState(null);
const [scheduled, setScheduled] = useState(null);
const [active, setActive] = useState(false);
- const { request, isLoading: loading, error: apiError } = useApiRequest();
+ const { request, isLoading: loading, error: apiError, clearCache } = useApiRequest();
const [error, setError] = useState(null);
const loadPlans = useCallback(async () => {
+ clearCache('/api/subscription/plans', 'get');
try {
const data = await fetchSelectablePlans(request, mandateId);
setPlans(Array.isArray(data) ? data : []);
@@ -53,9 +54,10 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
console.error('Error loading plans:', err);
setPlans([]);
}
- }, [request, mandateId]);
+ }, [request, mandateId, clearCache]);
const loadStatus = useCallback(async () => {
+ clearCache('/api/subscription/status', 'get');
try {
const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId);
setActive(data.active);
@@ -69,7 +71,7 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
setPlan(null);
setScheduled(null);
}
- }, [request, mandateId]);
+ }, [request, mandateId, clearCache]);
const activatePlan = useCallback(async (planKey: string) => {
try {
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx
index 07c3be0..6d72866 100644
--- a/src/pages/billing/BillingAdmin.tsx
+++ b/src/pages/billing/BillingAdmin.tsx
@@ -605,6 +605,7 @@ export const BillingAdmin: React.FC = () => {
const [adminTab, setAdminTab] = useState(
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
);
+ const [subscriptionTabKey, setSubscriptionTabKey] = useState(0);
useEffect(() => {
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
@@ -736,7 +737,7 @@ export const BillingAdmin: React.FC = () => {
setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
Guthaben
- setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
+ { setAdminTab('subscription'); setSubscriptionTabKey(k => k + 1); }} style={_tabStyle(adminTab === 'subscription')}>
Abonnement
setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
@@ -769,7 +770,7 @@ export const BillingAdmin: React.FC = () => {
)}
{adminTab === 'subscription' && (
-
+
)}
{adminTab === 'transactions' && (
From ca019ae28de8e649c0045028c949a8c1a621ad0f Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 01:12:29 +0200
Subject: [PATCH 14/23] fexed stripe webhook
---
src/pages/billing/SubscriptionTab.tsx | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/pages/billing/SubscriptionTab.tsx b/src/pages/billing/SubscriptionTab.tsx
index 413f798..29ad357 100644
--- a/src/pages/billing/SubscriptionTab.tsx
+++ b/src/pages/billing/SubscriptionTab.tsx
@@ -350,14 +350,21 @@ export const SubscriptionTab: React.FC = ({ mandateId }) =
if (sessionId && !verifyCalledRef.current) {
verifyCalledRef.current = true;
- verifyCheckout(sessionId)
- .then((result) => {
+ const _pollUntilActive = async (retries = 5, delayMs = 2000) => {
+ try {
+ const result = await verifyCheckout(sessionId);
if (result.status === 'activated') {
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
setJustPaid(false);
+ return;
}
- })
- .catch(() => {});
+ } catch { /* handled below via retry */ }
+ if (retries > 0) {
+ await new Promise(r => setTimeout(r, delayMs));
+ await _pollUntilActive(retries - 1, delayMs);
+ }
+ };
+ _pollUntilActive();
}
} else if (params.get('canceled') === 'true') {
setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' });
From 8fcad7de45cb1f1d7b105f5cdfa4559b0670c0db Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 21:53:14 +0200
Subject: [PATCH 15/23] add hard delete for mandates (SysAdmin only)
Made-with: Cursor
---
src/api/mandateApi.ts | 21 +++++++++++++-
src/hooks/useMandates.ts | 15 ++++++++++
src/pages/admin/AdminMandatesPage.tsx | 40 +++++++++++++++++++++++----
3 files changed, 70 insertions(+), 6 deletions(-)
diff --git a/src/api/mandateApi.ts b/src/api/mandateApi.ts
index b29d138..9f4076d 100644
--- a/src/api/mandateApi.ts
+++ b/src/api/mandateApi.ts
@@ -122,7 +122,7 @@ export async function createMandate(
}
/**
- * Delete a mandate
+ * Soft-delete a mandate (sets enabled=false, 30-day retention)
* Endpoint: DELETE /api/mandates/{mandateId}
*/
export async function deleteMandate(
@@ -134,3 +134,22 @@ export async function deleteMandate(
method: 'delete'
});
}
+
+/**
+ * Hard-delete a mandate with full cascade (irreversible)
+ * Endpoint: DELETE /api/mandates/{mandateId}?force=true
+ */
+export async function hardDeleteMandate(
+ request: ApiRequestFunction,
+ mandateId: string,
+ confirmName: string
+): Promise {
+ await request({
+ url: `/api/mandates/${mandateId}`,
+ method: 'delete',
+ params: { force: true },
+ additionalConfig: {
+ headers: { 'X-Confirm-Name': confirmName }
+ }
+ });
+}
diff --git a/src/hooks/useMandates.ts b/src/hooks/useMandates.ts
index a276200..f57c299 100644
--- a/src/hooks/useMandates.ts
+++ b/src/hooks/useMandates.ts
@@ -15,6 +15,7 @@ import {
createMandate as createMandateApi,
updateMandate as updateMandateApi,
deleteMandate as deleteMandateApi,
+ hardDeleteMandate as hardDeleteMandateApi,
type Mandate,
type MandateUpdateData,
type PaginationParams
@@ -203,6 +204,19 @@ export function useAdminMandates() {
}
}, [request, fetchMandates]);
+ // Hard-delete mandate (irreversible)
+ const handleHardDelete = useCallback(async (mandateId: string, confirmName: string): Promise => {
+ try {
+ removeOptimistically(mandateId);
+ await hardDeleteMandateApi(request, mandateId, confirmName);
+ return true;
+ } catch (error: any) {
+ console.error('Error hard-deleting mandate:', error);
+ await fetchMandates();
+ return false;
+ }
+ }, [request, fetchMandates]);
+
// Inline update
const handleInlineUpdate = useCallback(async (
mandateId: string,
@@ -231,6 +245,7 @@ export function useAdminMandates() {
handleCreate,
handleUpdate,
handleDelete,
+ handleHardDelete,
handleInlineUpdate,
updateOptimistically,
};
diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx
index cac150a..0ba5b04 100644
--- a/src/pages/admin/AdminMandatesPage.tsx
+++ b/src/pages/admin/AdminMandatesPage.tsx
@@ -17,7 +17,7 @@ import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
-import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
+import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => {
@@ -37,6 +37,7 @@ export const AdminMandatesPage: React.FC = () => {
handleCreate,
handleUpdate,
handleDelete,
+ handleHardDelete,
handleInlineUpdate,
updateOptimistically,
} = useAdminMandates();
@@ -118,17 +119,37 @@ export const AdminMandatesPage: React.FC = () => {
return;
}
const entered = await prompt(
- `Um den Mandanten "${mandate.name}" unwiderruflich zu löschen, geben Sie den Namen ein:`,
- { title: 'Mandant löschen', confirmLabel: 'Löschen', variant: 'danger', placeholder: mandate.name },
+ `Um den Mandanten "${mandate.name}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:`,
+ { title: 'Mandant deaktivieren', confirmLabel: 'Deaktivieren', variant: 'danger', placeholder: mandate.name },
);
if (entered === null) return;
if (entered !== mandate.name) {
- showWarning('Löschung abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
+ showWarning('Abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
return;
}
await handleDelete(mandate.id);
};
+ const handleHardDeleteMandate = async (mandate: Mandate) => {
+ if (mandate.isSystem) {
+ showWarning('Nicht erlaubt', 'System-Mandanten können nicht gelöscht werden.');
+ return;
+ }
+ const entered = await prompt(
+ `ACHTUNG: Dies löscht den Mandanten "${mandate.name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:`,
+ { title: 'Hard Delete (irreversibel)', confirmLabel: 'Endgültig löschen', variant: 'danger', placeholder: mandate.name },
+ );
+ if (entered === null) return;
+ if (entered !== mandate.name) {
+ showWarning('Abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
+ return;
+ }
+ const ok = await handleHardDelete(mandate.id, entered);
+ if (ok) {
+ showSuccess('Gelöscht', `Mandant "${mandate.name}" wurde endgültig gelöscht.`);
+ }
+ };
+
if (error) {
return (
@@ -218,12 +239,21 @@ export const AdminMandatesPage: React.FC = () => {
}] : []),
...(canDelete ? [{
type: 'delete' as const,
- title: 'Löschen',
+ title: 'Deaktivieren (Soft-Delete)',
disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false
}] : []),
]}
+ customActions={canDelete ? [{
+ id: 'hard-delete',
+ icon:
,
+ onClick: handleHardDeleteMandate,
+ title: 'Hard Delete (irreversibel)',
+ disabled: (row: Mandate) => row.isSystem
+ ? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
+ : false,
+ }] : []}
onDelete={handleDeleteMandate}
hookData={{
refetch,
From cba01a2d612893840cce32b2cfbdb9ec2a147556 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 22:01:02 +0200
Subject: [PATCH 16/23] fix hard delete icon color to inherit from button
Made-with: Cursor
---
src/pages/admin/AdminMandatesPage.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx
index 0ba5b04..06a6957 100644
--- a/src/pages/admin/AdminMandatesPage.tsx
+++ b/src/pages/admin/AdminMandatesPage.tsx
@@ -247,7 +247,7 @@ export const AdminMandatesPage: React.FC = () => {
]}
customActions={canDelete ? [{
id: 'hard-delete',
- icon: ,
+ icon: ,
onClick: handleHardDeleteMandate,
title: 'Hard Delete (irreversibel)',
disabled: (row: Mandate) => row.isSystem
From b96d3dad4a39b761d5636cef6e3f649c2af40a31 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 22:47:30 +0200
Subject: [PATCH 17/23] fix workspace sources: icon visibility, state sync,
color mapping, sort order
Made-with: Cursor
---
src/components/UnifiedDataBar/SourcesTab.tsx | 78 +++++++++++++------
.../UnifiedDataBar/UnifiedDataBar.tsx | 4 +-
src/pages/views/workspace/WorkspaceInput.tsx | 2 +-
src/pages/views/workspace/WorkspacePage.tsx | 6 ++
4 files changed, 65 insertions(+), 25 deletions(-)
diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx
index 4db4e7e..2370209 100644
--- a/src/components/UnifiedDataBar/SourcesTab.tsx
+++ b/src/components/UnifiedDataBar/SourcesTab.tsx
@@ -89,6 +89,7 @@ interface FeatureTableNode {
interface SourcesTabProps {
context: UdbContext;
+ onSourcesChanged?: () => void;
}
/* ─── Icons ──────────────────────────────────────────────────────────── */
@@ -115,27 +116,46 @@ const _SERVICE_ICONS: Record = {
const _SOURCE_COLORS: Record = {
sharepointFolder: '#0078d4',
+ sharepoint: '#0078d4',
onedriveFolder: '#0078d4',
+ onedrive: '#0078d4',
outlookFolder: '#0078d4',
+ outlook: '#0078d4',
googleDriveFolder: '#34a853',
+ drive: '#34a853',
gmailFolder: '#ea4335',
+ gmail: '#ea4335',
ftpFolder: '#795548',
+ files: '#795548',
+ 'local:ftp': '#795548',
+ 'local:jira': '#1976d2',
+ clickup: '#7b68ee',
};
function _getSourceColor(sourceType: string): string {
return _SOURCE_COLORS[sourceType] || '#1976d2';
}
+const _SOURCE_ICONS: Record = {
+ sharepointFolder: '\uD83D\uDCC1',
+ sharepoint: '\uD83D\uDCC1',
+ onedriveFolder: '\u2601\uFE0F',
+ onedrive: '\u2601\uFE0F',
+ outlookFolder: '\uD83D\uDCE7',
+ outlook: '\uD83D\uDCE7',
+ googleDriveFolder: '\uD83D\uDCC2',
+ drive: '\uD83D\uDCC2',
+ gmailFolder: '\uD83D\uDCE8',
+ gmail: '\uD83D\uDCE8',
+ ftpFolder: '\uD83D\uDD17',
+ files: '\uD83D\uDD17',
+ 'local:ftp': '\uD83D\uDD17',
+ 'local:jira': '\uD83D\uDD27',
+ clickup: '\uD83D\uDCCB',
+};
+
function _getSourceIcon(sourceType: string): string {
- const map: Record = {
- sharepointFolder: '\uD83D\uDCC1',
- onedriveFolder: '\u2601\uFE0F',
- outlookFolder: '\uD83D\uDCE7',
- googleDriveFolder: '\uD83D\uDCC2',
- gmailFolder: '\uD83D\uDCE8',
- ftpFolder: '\uD83D\uDD17',
- };
- return map[sourceType] || '\uD83D\uDCC1';
+ return _SOURCE_ICONS[sourceType] || '\uD83D\uDCC1';
}
/* ─── Scope / Neutralize constants ───────────────────────────────────── */
@@ -162,6 +182,15 @@ function _nextScope(current: string): string {
return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length];
}
+const _SERVICE_TO_SOURCE_TYPE: Record = {
+ sharepoint: 'sharepointFolder',
+ onedrive: 'onedriveFolder',
+ outlook: 'outlookFolder',
+ drive: 'googleDriveFolder',
+ gmail: 'gmailFolder',
+ files: 'ftpFolder',
+};
+
/* ─── Tree helpers ───────────────────────────────────────────────────── */
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
@@ -301,7 +330,7 @@ function _Spinner(): React.ReactElement {
/* ─── Component ──────────────────────────────────────────────────────── */
-const SourcesTab: React.FC = ({ context }) => {
+const SourcesTab: React.FC = ({ context, onSourcesChanged }) => {
const instanceId = context.instanceId;
/* ── Active sources (fetched internally) ── */
@@ -444,22 +473,15 @@ const SourcesTab: React.FC = ({ context }) => {
if (!node.service || !node.connectionId) return;
setAddingPath(node.key);
try {
- const sourceTypeMap: Record = {
- sharepoint: 'sharepointFolder',
- onedrive: 'onedriveFolder',
- outlook: 'outlookFolder',
- drive: 'googleDriveFolder',
- gmail: 'gmailFolder',
- files: 'ftpFolder',
- };
await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: node.connectionId,
- sourceType: sourceTypeMap[node.service] || node.service,
+ sourceType: _SERVICE_TO_SOURCE_TYPE[node.service] || node.service,
path: node.path || '/',
label: node.label,
displayPath: node.displayPath || node.label,
});
_fetchDataSources();
+ onSourcesChanged?.();
} catch (err) {
console.error('Failed to add data source:', err);
} finally {
@@ -472,15 +494,19 @@ const SourcesTab: React.FC = ({ context }) => {
try {
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
_fetchDataSources();
+ onSourcesChanged?.();
} catch (err) {
console.error('Failed to remove data source:', err);
}
}, [instanceId, _fetchDataSources]);
/* ── Check if a path is already added ── */
- const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
+ const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => {
+ const expectedSourceType = service ? (_SERVICE_TO_SOURCE_TYPE[service] || service) : undefined;
return dataSources.some(ds =>
- ds.connectionId === connectionId && ds.path === (path || '/'),
+ ds.connectionId === connectionId &&
+ ds.path === (path || '/') &&
+ (!expectedSourceType || ds.sourceType === expectedSourceType),
);
}, [dataSources]);
@@ -617,6 +643,7 @@ const SourcesTab: React.FC = ({ context }) => {
label: table.label?.en || table.label?.de || table.tableName,
});
_fetchFeatureDataSources();
+ onSourcesChanged?.();
} catch (err) {
console.error('Failed to add feature data source:', err);
} finally {
@@ -629,6 +656,7 @@ const SourcesTab: React.FC = ({ context }) => {
try {
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
_fetchFeatureDataSources();
+ onSourcesChanged?.();
} catch (err) {
console.error('Failed to remove feature data source:', err);
}
@@ -651,7 +679,11 @@ const SourcesTab: React.FC = ({ context }) => {
Active Personal Sources
- {dataSources.map(ds => {
+ {[...dataSources].sort((a, b) => {
+ const aKey = `${a.sourceType}|${a.label || a.path || ''}`;
+ const bKey = `${b.sourceType}|${b.label || b.path || ''}`;
+ return aKey.localeCompare(bKey);
+ }).map(ds => {
const connColor = _getSourceColor(ds.sourceType);
const connNode = tree.find(n => n.connectionId === ds.connectionId);
const connLabel = connNode?.label || ds.connectionId;
@@ -751,7 +783,7 @@ const SourcesTab: React.FC = ({ context }) => {
Active Feature Sources
- {featureDataSources.map(fds => {
+ {[...featureDataSources].sort((a, b) => (a.label || a.tableName || '').localeCompare(b.label || b.tableName || '')).map(fds => {
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
const fdsConnLabel = meta?.instanceLabel || fds.tableName;
return (
diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
index 8a6ddc9..75bf641 100644
--- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx
+++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
@@ -25,6 +25,7 @@ interface UnifiedDataBarProps {
onDeleteChat?: (chatId: string) => void;
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string) => void;
+ onSourcesChanged?: () => void;
className?: string;
}
@@ -46,6 +47,7 @@ const UnifiedDataBar: React.FC = ({
onDeleteChat,
onChatDragStart,
onFileSelect,
+ onSourcesChanged,
className,
}) => {
const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter(
@@ -91,7 +93,7 @@ const UnifiedDataBar: React.FC = ({
/>
)}
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
-
+
)}
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx
index 7138661..affed12 100644
--- a/src/pages/views/workspace/WorkspaceInput.tsx
+++ b/src/pages/views/workspace/WorkspaceInput.tsx
@@ -543,7 +543,7 @@ export const WorkspaceInput: React.FC
= ({
{uploading ? '...' : '+'}
- {dataSources.length > 0 && (
+ {(dataSources.length > 0 || featureDataSources.length > 0) && (
setShowSourcePicker(prev => !prev)}
diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx
index d333bd5..9a11ed9 100644
--- a/src/pages/views/workspace/WorkspacePage.tsx
+++ b/src/pages/views/workspace/WorkspacePage.tsx
@@ -254,6 +254,11 @@ export const WorkspacePage: React.FC = ({ persistentInstance
featureInstanceId: instanceId,
};
+ const _handleSourcesChanged = useCallback(() => {
+ workspace.refreshDataSources();
+ workspace.refreshFeatureDataSources();
+ }, [workspace]);
+
const _leftPanelBody = (
= ({ persistentInstance
onRenameChat={_handleRenameChat}
onDeleteChat={_handleDeleteChat}
onFileSelect={_handleFileSelect}
+ onSourcesChanged={_handleSourcesChanged}
/>
);
From 66708d674341c8e0ce8f7e7fbb6b965fc80103ea Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 23:41:02 +0200
Subject: [PATCH 18/23] fixed data source
---
src/components/UnifiedDataBar/SourcesTab.tsx | 538 +++++++++++++++++--
src/pages/views/workspace/useWorkspace.ts | 1 +
2 files changed, 491 insertions(+), 48 deletions(-)
diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx
index 2370209..57d6485 100644
--- a/src/components/UnifiedDataBar/SourcesTab.tsx
+++ b/src/components/UnifiedDataBar/SourcesTab.tsx
@@ -42,6 +42,7 @@ interface UdbFeatureDataSource {
label: string;
scope: string;
neutralize: boolean;
+ recordFilter?: Record;
}
interface TreeNode {
@@ -69,6 +70,7 @@ interface FeatureConnectionNode {
expanded: boolean;
loading: boolean;
tables: FeatureTableNode[] | null;
+ parentRecords: Record;
}
interface MandateGroupNode {
@@ -83,6 +85,18 @@ interface FeatureTableNode {
tableName: string;
label: Record;
fields: string[];
+ isParent?: boolean;
+ parentTable?: string;
+ parentKey?: string;
+ displayFields?: string[];
+}
+
+interface ParentRecordNode {
+ id: string;
+ displayLabel: string;
+ fields: Record;
+ tableName: string;
+ expanded: boolean;
}
/* ─── Props ──────────────────────────────────────────────────────────── */
@@ -386,6 +400,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) =>
label: d.label,
scope: d.scope || 'personal',
neutralize: d.neutralize ?? false,
+ recordFilter: d.recordFilter || undefined,
}));
setFeatureDataSources(list);
})
@@ -576,6 +591,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) =>
expanded: false,
loading: false,
tables: null,
+ parentRecords: {},
})),
})));
})
@@ -615,6 +631,10 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) =>
tableName: t.tableName,
label: t.label || {},
fields: t.fields || [],
+ isParent: t.isParent || false,
+ parentTable: t.parentTable || undefined,
+ parentKey: t.parentKey || undefined,
+ displayFields: t.displayFields || undefined,
}));
if (mountedRef.current) {
setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
@@ -669,6 +689,119 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) =>
);
}, [featureDataSources]);
+ /* ── Parent groups: expand/collapse + load records ── */
+ const [expandedParentGroups, setExpandedParentGroups] = useState>(new Set());
+ const [loadingParentGroup, setLoadingParentGroup] = useState(null);
+ const [addingParentKey, setAddingParentKey] = useState(null);
+
+ const _toggleParentGroup = useCallback(async (node: FeatureConnectionNode, parentTableName: string) => {
+ const groupKey = `${node.featureInstanceId}-${parentTableName}`;
+
+ if (expandedParentGroups.has(groupKey)) {
+ setExpandedParentGroups(prev => { const next = new Set(prev); next.delete(groupKey); return next; });
+ return;
+ }
+
+ setExpandedParentGroups(prev => new Set(prev).add(groupKey));
+
+ if (node.parentRecords[parentTableName]) return;
+
+ setLoadingParentGroup(groupKey);
+ try {
+ const res = await api.get(
+ `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${parentTableName}`,
+ );
+ const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({
+ id: r.id,
+ displayLabel: r.displayLabel || r.id,
+ fields: r.fields || {},
+ tableName: parentTableName,
+ expanded: false,
+ }));
+ if (mountedRef.current) {
+ setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
+ ...n,
+ parentRecords: { ...n.parentRecords, [parentTableName]: records },
+ })));
+ }
+ } catch {
+ if (mountedRef.current) {
+ setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({
+ ...n,
+ parentRecords: { ...n.parentRecords, [parentTableName]: [] },
+ })));
+ }
+ } finally {
+ if (mountedRef.current) setLoadingParentGroup(null);
+ }
+ }, [instanceId, expandedParentGroups]);
+
+ const _toggleParentRecord = useCallback((featureInstanceId: string, parentTableName: string, recordId: string) => {
+ setFeatureTree(prev => _mapFeatureTreeUpdate(prev, featureInstanceId, n => ({
+ ...n,
+ parentRecords: {
+ ...n.parentRecords,
+ [parentTableName]: (n.parentRecords[parentTableName] || []).map(r =>
+ r.id === recordId ? { ...r, expanded: !r.expanded } : r,
+ ),
+ },
+ })));
+ }, []);
+
+ /* ── Parent record: add parent + all children with recordFilter ── */
+ const _addParentRecord = useCallback(async (
+ node: FeatureConnectionNode,
+ parentRecord: ParentRecordNode,
+ allTables: FeatureTableNode[],
+ ) => {
+ const addKey = `${node.featureInstanceId}-parent-${parentRecord.id}`;
+ setAddingParentKey(addKey);
+ try {
+ const parentTable = allTables.find(t => t.tableName === parentRecord.tableName && t.isParent);
+ const childTables = allTables.filter(t => t.parentTable === parentRecord.tableName);
+
+ if (parentTable) {
+ const parentLabel = `${parentTable.label?.en || parentTable.label?.de || parentTable.tableName}: ${parentRecord.displayLabel}`;
+ await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
+ featureInstanceId: node.featureInstanceId,
+ featureCode: node.featureCode,
+ tableName: parentTable.tableName,
+ objectKey: parentTable.objectKey,
+ label: parentLabel,
+ recordFilter: { id: parentRecord.id },
+ });
+ }
+
+ for (const child of childTables) {
+ const childLabel = `${child.label?.en || child.label?.de || child.tableName}: ${parentRecord.displayLabel}`;
+ await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
+ featureInstanceId: node.featureInstanceId,
+ featureCode: node.featureCode,
+ tableName: child.tableName,
+ objectKey: child.objectKey,
+ label: childLabel,
+ recordFilter: { [child.parentKey!]: parentRecord.id },
+ });
+ }
+
+ _fetchFeatureDataSources();
+ onSourcesChanged?.();
+ } catch (err) {
+ console.error('Failed to add parent record sources:', err);
+ } finally {
+ if (mountedRef.current) setAddingParentKey(null);
+ }
+ }, [instanceId, _fetchFeatureDataSources]);
+
+ /* ── Check if a parent record is already added ── */
+ const _isParentRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => {
+ return featureDataSources.some(fds =>
+ fds.featureInstanceId === featureInstanceId &&
+ fds.tableName === parentTableName &&
+ fds.recordFilter?.id === recordId,
+ );
+ }, [featureDataSources]);
+
/* ── Render ── */
return (
@@ -777,60 +910,139 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) =>
{/* ── Divider ── */}
- {/* ── Active Feature Sources ── */}
+ {/* ── Active Feature Sources (grouped by parent record) ── */}
{featureDataSources.length > 0 && (
Active Feature Sources
- {[...featureDataSources].sort((a, b) => (a.label || a.tableName || '').localeCompare(b.label || b.tableName || '')).map(fds => {
- const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
- const fdsConnLabel = meta?.instanceLabel || fds.tableName;
+ {(() => {
+ const sorted = [...featureDataSources].sort((a, b) => (a.label || a.tableName || '').localeCompare(b.label || b.tableName || ''));
+ const grouped: { key: string; label: string; items: UdbFeatureDataSource[] }[] = [];
+ const standalone: UdbFeatureDataSource[] = [];
+
+ for (const fds of sorted) {
+ if (fds.recordFilter && Object.keys(fds.recordFilter).length > 0) {
+ const filterKey = `${fds.featureInstanceId}|${JSON.stringify(fds.recordFilter)}`;
+ let group = grouped.find(g => g.key === filterKey);
+ if (!group) {
+ const parentLabel = fds.label.includes(':') ? fds.label.split(':')[1]?.trim() : fds.label;
+ const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
+ group = { key: filterKey, label: `${meta?.instanceLabel || fds.featureCode} – ${parentLabel}`, items: [] };
+ grouped.push(group);
+ }
+ group.items.push(fds);
+ } else {
+ standalone.push(fds);
+ }
+ }
+
return (
-
-
- {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
-
-
- {fdsConnLabel} – {fds.tableName}
-
- _cycleFeatureScope(fds)}
- style={{
- background: 'none', border: 'none', cursor: 'pointer',
- fontSize: 13, padding: '0 2px', lineHeight: 1,
- }}
- title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope} → ${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
- >
- {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
-
- _toggleFeatureNeutralize(fds)}
- style={{
- background: 'none', border: 'none', cursor: 'pointer',
- fontSize: 13, padding: '0 2px', lineHeight: 1,
- opacity: fds.neutralize ? 1 : 0.35,
- }}
- title={fds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
- >
- {'\uD83D\uDD12'}
-
- _removeFeatureDataSource(fds.id)}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
- title="Entfernen"
- >
- {'\u2715'}
-
-
+ <>
+ {grouped.map(group => (
+
+
+ {'\uD83D\uDCCB'}
+
+ {group.label}
+
+ { group.items.forEach(fds => _removeFeatureDataSource(fds.id)); }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
+ title="Remove all tables for this record"
+ >
+ {'\u2715'}
+
+
+ {group.items.map(fds => {
+ const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
+ return (
+
+
+ {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDCC4'}
+
+
+ {fds.tableName}
+
+ _cycleFeatureScope(fds)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1 }}
+ title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope} → ${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
+ >
+ {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
+
+ _toggleFeatureNeutralize(fds)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
+ title={fds.neutralize ? 'Neutralize: ON' : 'Neutralize: OFF'}
+ >
+ {'\uD83D\uDD12'}
+
+ _removeFeatureDataSource(fds.id)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, color: '#999', padding: '0 2px' }}
+ title="Remove"
+ >
+ {'\u2715'}
+
+
+ );
+ })}
+
+ ))}
+ {standalone.map(fds => {
+ const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
+ const fdsConnLabel = meta?.instanceLabel || fds.tableName;
+ return (
+
+
+ {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
+
+
+ {fdsConnLabel} – {fds.tableName}
+
+ _cycleFeatureScope(fds)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, padding: '0 2px', lineHeight: 1 }}
+ title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope} → ${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
+ >
+ {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
+
+ _toggleFeatureNeutralize(fds)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, padding: '0 2px', lineHeight: 1, opacity: fds.neutralize ? 1 : 0.35 }}
+ title={fds.neutralize ? 'Neutralize: ON' : 'Neutralize: OFF'}
+ >
+ {'\uD83D\uDD12'}
+
+ _removeFeatureDataSource(fds.id)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
+ title="Entfernen"
+ >
+ {'\u2715'}
+
+
+ );
+ })}
+ >
);
- })}
+ })()}
)}
@@ -871,6 +1083,13 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) =>
onAddTable={_addFeatureTable}
isTableAdded={_isFeatureTableAdded}
addingKey={addingFeatureKey}
+ onToggleParentGroup={_toggleParentGroup}
+ onToggleParentRecord={_toggleParentRecord}
+ onAddParentRecord={_addParentRecord}
+ isParentRecordAdded={_isParentRecordAdded}
+ expandedParentGroups={expandedParentGroups}
+ loadingParentGroup={loadingParentGroup}
+ addingParentKey={addingParentKey}
/>
))}
@@ -990,10 +1209,19 @@ interface _MandateGroupViewProps {
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
addingKey: string | null;
+ onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void;
+ onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void;
+ onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void;
+ isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean;
+ expandedParentGroups: Set;
+ loadingParentGroup: string | null;
+ addingParentKey: string | null;
}
const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
+ onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
+ expandedParentGroups, loadingParentGroup, addingParentKey,
}) => {
const [hovered, setHovered] = useState(false);
const chevron = group.expanded ? '\u25BE' : '\u25B8';
@@ -1030,6 +1258,13 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
onAddTable={onAddTable}
isTableAdded={isTableAdded}
addingKey={addingKey}
+ onToggleParentGroup={onToggleParentGroup}
+ onToggleParentRecord={onToggleParentRecord}
+ onAddParentRecord={onAddParentRecord}
+ isParentRecordAdded={isParentRecordAdded}
+ expandedParentGroups={expandedParentGroups}
+ loadingParentGroup={loadingParentGroup}
+ addingParentKey={addingParentKey}
/>
))}
@@ -1046,14 +1281,26 @@ interface _FeatureNodeViewProps {
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
isTableAdded: (featureInstanceId: string, tableName: string) => boolean;
addingKey: string | null;
+ onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void;
+ onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void;
+ onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void;
+ isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean;
+ expandedParentGroups: Set;
+ loadingParentGroup: string | null;
+ addingParentKey: string | null;
}
const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
node, onToggle, onAddTable, isTableAdded, addingKey,
+ onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded,
+ expandedParentGroups, loadingParentGroup, addingParentKey,
}) => {
const [hovered, setHovered] = useState(false);
const chevron = node.expanded ? '\u25BE' : '\u25B8';
+ const parentTables = (node.tables || []).filter(t => t.isParent);
+ const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable);
+
return (
= ({
{node.expanded && node.tables && node.tables.length > 0 && (
- {node.tables.map(table => (
+ {/* Parent table groups (hierarchical) */}
+ {parentTables.map(pt => {
+ const groupKey = `${node.featureInstanceId}-${pt.tableName}`;
+ const isGroupExpanded = expandedParentGroups.has(groupKey);
+ const isGroupLoading = loadingParentGroup === groupKey;
+ const records = node.parentRecords[pt.tableName];
+ const childTables = (node.tables || []).filter(t => t.parentTable === pt.tableName);
+ const ptLabel = pt.label?.en || pt.label?.de || pt.tableName;
+
+ return (
+ <_ParentGroupView
+ key={groupKey}
+ featureNode={node}
+ parentTable={pt}
+ label={ptLabel}
+ expanded={isGroupExpanded}
+ loading={isGroupLoading}
+ records={records || null}
+ childTables={childTables}
+ allTables={node.tables!}
+ onToggleGroup={() => onToggleParentGroup(node, pt.tableName)}
+ onToggleRecord={(recordId) => onToggleParentRecord(node.featureInstanceId, pt.tableName, recordId)}
+ onAddRecord={(record) => onAddParentRecord(node, record, node.tables!)}
+ isRecordAdded={(recordId) => isParentRecordAdded(node.featureInstanceId, pt.tableName, recordId)}
+ addingParentKey={addingParentKey}
+ />
+ );
+ })}
+
+ {/* Standalone tables (not part of any hierarchy) */}
+ {standaloneTables.map(table => (
<_FeatureTableRow
key={table.objectKey}
featureNode={node}
@@ -1163,4 +1440,169 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
);
};
+/* ─── ParentGroupView (parent table → parent records) ────────────────── */
+
+interface _ParentGroupViewProps {
+ featureNode: FeatureConnectionNode;
+ parentTable: FeatureTableNode;
+ label: string;
+ expanded: boolean;
+ loading: boolean;
+ records: ParentRecordNode[] | null;
+ childTables: FeatureTableNode[];
+ allTables: FeatureTableNode[];
+ onToggleGroup: () => void;
+ onToggleRecord: (recordId: string) => void;
+ onAddRecord: (record: ParentRecordNode) => void;
+ isRecordAdded: (recordId: string) => boolean;
+ addingParentKey: string | null;
+}
+
+const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({
+ featureNode, parentTable, label, expanded, loading, records, childTables, allTables,
+ onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey,
+}) => {
+ const [hovered, setHovered] = useState(false);
+ const chevron = expanded ? '\u25BE' : '\u25B8';
+
+ return (
+
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ paddingLeft: 24, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ cursor: 'pointer', borderRadius: 3,
+ background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
+ transition: 'background 0.1s', userSelect: 'none',
+ }}
+ >
+
+ {loading ? _Spinner() : chevron}
+
+ {'\uD83D\uDCC2'}
+
+ {label}
+
+ {childTables.length > 0 && (
+
+ +{childTables.length} tables
+
+ )}
+
+
+ {expanded && records && records.length > 0 && (
+
+ {records.map(record => (
+ <_ParentRecordRow
+ key={record.id}
+ featureNode={featureNode}
+ record={record}
+ childTables={childTables}
+ allTables={allTables}
+ onToggle={() => onToggleRecord(record.id)}
+ onAdd={() => onAddRecord(record)}
+ isAdded={isRecordAdded(record.id)}
+ isAdding={addingParentKey === `${featureNode.featureInstanceId}-parent-${record.id}`}
+ />
+ ))}
+
+ )}
+
+ {expanded && records && records.length === 0 && !loading && (
+
+ (no records)
+
+ )}
+
+ );
+};
+
+/* ─── ParentRecordRow (single parent record + child tables info) ─────── */
+
+interface _ParentRecordRowProps {
+ featureNode: FeatureConnectionNode;
+ record: ParentRecordNode;
+ childTables: FeatureTableNode[];
+ allTables: FeatureTableNode[];
+ onToggle: () => void;
+ onAdd: () => void;
+ isAdded: boolean;
+ isAdding: boolean;
+}
+
+const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
+ featureNode, record, childTables, allTables,
+ onToggle, onAdd, isAdded, isAdding,
+}) => {
+ const [hovered, setHovered] = useState(false);
+ const chevron = record.expanded ? '\u25BE' : '\u25B8';
+
+ return (
+
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ paddingLeft: 44, paddingRight: 4, paddingTop: 3, paddingBottom: 3,
+ cursor: 'pointer', borderRadius: 3,
+ background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent',
+ transition: 'background 0.1s', userSelect: 'none',
+ }}
+ title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')}
+ >
+
+ {chevron}
+
+ {'\uD83D\uDCCB'}
+
+ {record.displayLabel}
+
+ {hovered && !isAdded && (
+ { e.stopPropagation(); onAdd(); }}
+ disabled={isAdding}
+ style={{
+ background: 'none', border: '1px solid #7b1fa2', borderRadius: 3,
+ cursor: isAdding ? 'not-allowed' : 'pointer',
+ fontSize: 10, color: '#7b1fa2', padding: '1px 5px',
+ opacity: isAdding ? 0.5 : 1, flexShrink: 0,
+ }}
+ title="Add all tables for this record"
+ >
+ {isAdding ? '...' : '+ Add'}
+
+ )}
+ {isAdded && (
+
+ {'\u2713'}
+
+ )}
+
+
+ {record.expanded && (
+
+ {childTables.map(ct => {
+ const ctLabel = ct.label?.en || ct.label?.de || ct.tableName;
+ return (
+
+ {'\uD83D\uDCC4'}
+ {ctLabel}
+ ({ct.parentKey})
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
export default SourcesTab;
diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts
index c5dc589..0fcff28 100644
--- a/src/pages/views/workspace/useWorkspace.ts
+++ b/src/pages/views/workspace/useWorkspace.ts
@@ -71,6 +71,7 @@ export interface FeatureDataSource {
label: string;
mandateId: string;
workspaceInstanceId: string;
+ recordFilter?: Record
;
}
export interface FileEditProposal {
From 2509fbdcf2b80cf704e0e2caf368cc929e5522c7 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 31 Mar 2026 23:52:41 +0200
Subject: [PATCH 19/23] fix TS build: prefix unused destructured props with
underscore
Made-with: Cursor
---
src/components/UnifiedDataBar/SourcesTab.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx
index 57d6485..dc9b3f7 100644
--- a/src/components/UnifiedDataBar/SourcesTab.tsx
+++ b/src/components/UnifiedDataBar/SourcesTab.tsx
@@ -1459,7 +1459,7 @@ interface _ParentGroupViewProps {
}
const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({
- featureNode, parentTable, label, expanded, loading, records, childTables, allTables,
+ featureNode, parentTable: _parentTable, label, expanded, loading, records, childTables, allTables,
onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey,
}) => {
const [hovered, setHovered] = useState(false);
@@ -1534,7 +1534,7 @@ interface _ParentRecordRowProps {
}
const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({
- featureNode, record, childTables, allTables,
+ featureNode: _featureNode, record, childTables, allTables: _allTables,
onToggle, onAdd, isAdded, isAdding,
}) => {
const [hovered, setHovered] = useState(false);
From d3d054b1326941741bd0eccc6e50d943257dcbfa Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 1 Apr 2026 22:04:02 +0200
Subject: [PATCH 20/23] commcoach agent integration: keep-alive persistence,
input bar, voice controller fixes
Made-with: Cursor
---
src/api/commcoachApi.ts | 16 +-
src/hooks/useCommcoach.ts | 38 +-
src/layouts/MainLayout.tsx | 10 +-
src/pages/FeatureView.tsx | 5 +
.../commcoach/CommcoachDossierView.module.css | 109 +++++
.../views/commcoach/CommcoachDossierView.tsx | 417 ++++++++++++++++--
.../views/commcoach/CommcoachKeepAlive.tsx | 55 +++
.../views/commcoach/useVoiceController.ts | 14 +
8 files changed, 617 insertions(+), 47 deletions(-)
create mode 100644 src/pages/views/commcoach/CommcoachKeepAlive.tsx
diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts
index 47f5665..df0ed6c 100644
--- a/src/api/commcoachApi.ts
+++ b/src/api/commcoachApi.ts
@@ -285,6 +285,13 @@ export async function cancelSessionApi(request: ApiRequestFunction, instanceId:
// Streaming Chat API
// ============================================================================
+export interface SendMessageOptions {
+ fileIds?: string[];
+ dataSourceIds?: string[];
+ featureDataSourceIds?: string[];
+ allowedProviders?: string[];
+}
+
export async function sendMessageStreamApi(
instanceId: string,
sessionId: string,
@@ -293,6 +300,7 @@ export async function sendMessageStreamApi(
onError?: (error: Error) => void,
onComplete?: () => void,
signal?: AbortSignal,
+ options?: SendMessageOptions,
): Promise {
try {
const baseURL = api.defaults.baseURL || '';
@@ -304,10 +312,16 @@ export async function sendMessageStreamApi(
if (!getCSRFToken()) generateAndStoreCSRFToken();
addCSRFTokenToHeaders(headers);
+ const body: Record = { content };
+ if (options?.fileIds?.length) body.fileIds = options.fileIds;
+ if (options?.dataSourceIds?.length) body.dataSourceIds = options.dataSourceIds;
+ if (options?.featureDataSourceIds?.length) body.featureDataSourceIds = options.featureDataSourceIds;
+ if (options?.allowedProviders?.length) body.allowedProviders = options.allowedProviders;
+
const response = await fetch(url, {
method: 'POST',
headers,
- body: JSON.stringify({ content }),
+ body: JSON.stringify(body),
credentials: 'include',
signal,
});
diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts
index 1e14ed1..62ec296 100644
--- a/src/hooks/useCommcoach.ts
+++ b/src/hooks/useCommcoach.ts
@@ -14,6 +14,7 @@ import {
createTaskApi, updateTaskStatusApi, deleteTaskApi,
type CoachingContext, type CoachingSession, type CoachingMessage,
type CoachingTask, type CoachingScore, type SSEEvent,
+ type SendMessageOptions,
} from '../api/commcoachApi';
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
@@ -37,12 +38,14 @@ export interface CommcoachHookReturn {
inputValue: string;
setInputValue: (v: string) => void;
+ agentToolCalls: Array<{ toolName: string; args?: Record; result?: string; success?: boolean }>;
+
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise;
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise;
archiveContext: (contextId: string) => Promise;
startSession: (personaId?: string) => Promise;
- sendMessage: (content: string) => Promise;
+ sendMessage: (content: string, options?: SendMessageOptions) => Promise;
sendAudio: (audioBlob: Blob) => Promise;
completeSession: () => Promise;
cancelSession: () => Promise;
@@ -67,9 +70,10 @@ export interface CommcoachHookReturn {
refreshContexts: () => Promise;
}
-export function useCommcoach(): CommcoachHookReturn {
+export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
const { request } = useApiRequest();
- const instanceId = useInstanceId();
+ const routeInstanceId = useInstanceId();
+ const instanceId = instanceIdOverride || routeInstanceId;
const [contexts, setContexts] = useState([]);
const [selectedContextId, setSelectedContextId] = useState(null);
@@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn {
const [error, setError] = useState(null);
const [inputValue, setInputValue] = useState('');
+ const [agentToolCalls, setAgentToolCalls] = useState; result?: string; success?: boolean }>>([]);
const [actionLoading, setActionLoading] = useState(null);
@@ -239,6 +244,7 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null);
setIsStreaming(true);
setStreamingStatus(null);
+ setStreamingMessage(null);
setMessages([]);
setSession(null);
try {
@@ -259,7 +265,7 @@ export function useCommcoach(): CommcoachHookReturn {
setMessages(eventData.messages);
}
} else if (eventType === 'messageChunk' && eventData) {
- setStreamingMessage(eventData.accumulated || '');
+ setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
} else if (eventType === 'message' && eventData) {
setStreamingMessage(null);
const msg: CoachingMessage = {
@@ -313,7 +319,7 @@ export function useCommcoach(): CommcoachHookReturn {
}
}, [instanceId, selectedContextId, ttsPlayback.play]);
- const sendMessage = useCallback(async (content: string) => {
+ const sendMessage = useCallback(async (content: string, options?: SendMessageOptions) => {
const normalizedContent = content.trim();
if (!normalizedContent || !instanceId || !session) return;
@@ -326,6 +332,8 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null);
setIsStreaming(true);
setStreamingStatus(null);
+ setStreamingMessage(null);
+ setAgentToolCalls([]);
const tempMsg: CoachingMessage = {
id: `temp-${Date.now()}`,
@@ -350,7 +358,7 @@ export function useCommcoach(): CommcoachHookReturn {
const eventData = event.data;
if (eventType === 'messageChunk' && eventData) {
- setStreamingMessage(eventData.accumulated || '');
+ setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
} else if (eventType === 'message' && eventData) {
setStreamingMessage(null);
const msg: CoachingMessage = {
@@ -374,6 +382,17 @@ export function useCommcoach(): CommcoachHookReturn {
ttsPlayback.play(eventData.audio);
} else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null);
+ } else if (eventType === 'toolCall' && eventData) {
+ setAgentToolCalls(prev => [...prev, { toolName: eventData.toolName, args: eventData.args }]);
+ setStreamingStatus(`Tool: ${eventData.toolName}...`);
+ } else if (eventType === 'toolResult' && eventData) {
+ setAgentToolCalls(prev => prev.map((tc, idx) =>
+ idx === prev.length - 1
+ ? { ...tc, result: eventData.data?.slice(0, 200), success: eventData.success }
+ : tc
+ ));
+ } else if (eventType === 'agentProgress' && eventData) {
+ setStreamingStatus(`Runde ${eventData.round}/${eventData.maxRounds}...`);
} else if (eventType === 'taskCreated' && eventData) {
setTasks(prev => [eventData, ...prev]);
} else if (eventType === 'documentCreated' && eventData) {
@@ -400,6 +419,7 @@ export function useCommcoach(): CommcoachHookReturn {
}
},
ac.signal,
+ options,
);
} catch (err: any) {
if (err.name === 'AbortError') return;
@@ -417,6 +437,7 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null);
setIsStreaming(true);
setStreamingStatus(null);
+ setStreamingMessage(null);
try {
await sendAudioStreamApi(
instanceId,
@@ -427,7 +448,9 @@ export function useCommcoach(): CommcoachHookReturn {
const eventType = event.type;
const eventData = event.data;
- if (eventType === 'status' && eventData) {
+ if (eventType === 'messageChunk' && eventData) {
+ setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
+ } else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null);
} else if (eventType === 'message' && eventData) {
if (eventData.role === 'assistant') setError(null);
@@ -555,6 +578,7 @@ export function useCommcoach(): CommcoachHookReturn {
session, messages, isStreaming, streamingStatus, streamingMessage,
tasks, scores, sessions,
error, inputValue, setInputValue,
+ agentToolCalls,
selectContext, createContext, archiveContext,
startSession: startSessionCb,
sendMessage, sendAudio,
diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx
index dd1cd70..579ab64 100644
--- a/src/layouts/MainLayout.tsx
+++ b/src/layouts/MainLayout.tsx
@@ -11,9 +11,11 @@ import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
+import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import styles from './MainLayout.module.css';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
+const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
// =============================================================================
// INNER LAYOUT (mit Zugriff auf Store)
@@ -23,6 +25,9 @@ const MainLayoutInner: React.FC = () => {
const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
+ const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
+ const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
+ const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible;
// Features laden beim Mount
useEffect(() => {
@@ -105,11 +110,12 @@ const MainLayoutInner: React.FC = () => {
/>
-
+
+
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx
index e3ebc03..03567d6 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -219,6 +219,11 @@ export const FeatureViewPage: React.FC
= ({ view }) => {
return null;
}
+ // CommCoach coaching/dossier is rendered persistently by CommcoachKeepAlive at MainLayout level.
+ if (featureCode === 'commcoach' && (view === 'coaching' || view === 'dossier')) {
+ return null;
+ }
+
// View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) {
diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css
index 006680c..459578d 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.module.css
+++ b/src/pages/views/commcoach/CommcoachDossierView.module.css
@@ -406,6 +406,115 @@
.typingDots { animation: blink 1.4s infinite both; }
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } }
+.agentActivityPanel {
+ margin: 0 1rem 0.75rem;
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 10px;
+ background: var(--bg-card, #fff);
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+.agentActivityHeader {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.65rem 0.9rem;
+ background: var(--bg-hover, #f8f8f8);
+ border: none;
+ cursor: pointer;
+ text-align: left;
+ color: var(--text-primary, #333);
+}
+
+.agentActivityTitle {
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.agentActivityStatus {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.78rem;
+ color: var(--text-secondary, #777);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.agentActivityChevron {
+ font-size: 0.8rem;
+ color: var(--text-secondary, #777);
+}
+
+.agentActivityBody {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+ padding: 0.8rem 0.9rem;
+ max-height: 220px;
+ overflow-y: auto;
+}
+
+.agentActivityEmpty {
+ font-size: 0.8rem;
+ color: var(--text-secondary, #777);
+}
+
+.agentActivityItem {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--border-color, #ededed);
+ border-radius: 8px;
+ background: var(--bg-secondary, #fafafa);
+}
+
+.agentActivityItemHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.agentActivityToolName {
+ font-size: 0.82rem;
+ font-weight: 600;
+ color: var(--text-primary, #333);
+}
+
+.agentActivityBadge {
+ padding: 0.12rem 0.42rem;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.agentActivityBadgeRunning {
+ background: #e3f2fd;
+ color: #1565c0;
+}
+
+.agentActivityBadgeSuccess {
+ background: #e8f5e9;
+ color: #2e7d32;
+}
+
+.agentActivityBadgeError {
+ background: #fde8e8;
+ color: #c62828;
+}
+
+.agentActivityMeta {
+ font-size: 0.76rem;
+ color: var(--text-secondary, #666);
+ line-height: 1.45;
+ word-break: break-word;
+}
+
/* Input Area */
.inputArea {
display: flex;
diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx
index 3582bf5..c584a04 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.tsx
+++ b/src/pages/views/commcoach/CommcoachDossierView.tsx
@@ -15,22 +15,58 @@ import {
getDossierExportUrl, getSessionExportUrl,
getScoreHistoryApi, getPersonasApi,
type CoachingPersona,
+ type SendMessageOptions,
} from '../../../api/commcoachApi';
+import api from '../../../api';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
+import { ProviderMultiSelect, _defaultProviderSelection } from '../../../components/ProviderSelector';
+import type { ProviderSelection } from '../../../components/ProviderSelector';
+import { getPageIcon } from '../../../config/pageRegistry';
import styles from './CommcoachDossierView.module.css';
import { useVoiceController } from './useVoiceController';
+interface WorkspaceFileInfo {
+ id: string;
+ fileName: string;
+ mimeType: string;
+ fileSize: number;
+}
+interface DataSourceInfo {
+ id: string;
+ connectionId: string;
+ sourceType: string;
+ path: string;
+ label: string;
+}
+interface FeatureDataSourceInfo {
+ id: string;
+ featureInstanceId: string;
+ featureCode: string;
+ tableName: string;
+ label: string;
+}
+
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
-export const CommcoachDossierView: React.FC = () => {
- const coach = useCommcoach();
+interface CommcoachDossierViewProps {
+ persistentInstanceId?: string;
+ persistentMandateId?: string;
+}
+
+export const CommcoachDossierView: React.FC = ({
+ persistentInstanceId,
+ persistentMandateId,
+}) => {
+ const routeInstanceId = useInstanceId();
+ const routeMandateId = useMandateId();
+ const instanceId = persistentInstanceId || routeInstanceId;
+ const mandateId = persistentMandateId || routeMandateId;
+ const coach = useCommcoach(instanceId);
const { request } = useApiRequest();
- const instanceId = useInstanceId();
- const mandateId = useMandateId();
const [activeTab, setActiveTab] = useState('coaching');
const [showNewContext, setShowNewContext] = useState(false);
@@ -45,6 +81,17 @@ export const CommcoachDossierView: React.FC = () => {
const [personas, setPersonas] = useState([]);
const [selectedPersonaId, setSelectedPersonaId] = useState(undefined);
+ const [wsFiles, setWsFiles] = useState([]);
+ const [wsDataSources, setWsDataSources] = useState([]);
+ const [wsFeatureDataSources, setWsFeatureDataSources] = useState([]);
+ const [attachedFileIds, setAttachedFileIds] = useState([]);
+ const [attachedDsIds, setAttachedDsIds] = useState([]);
+ const [attachedFdsIds, setAttachedFdsIds] = useState([]);
+ const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection);
+ const [showSourcePicker, setShowSourcePicker] = useState(false);
+ const [showFilePicker, setShowFilePicker] = useState(false);
+ const [showAgentActivity, setShowAgentActivity] = useState(true);
+
const _udbContext: UdbContext | null = instanceId
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
: null;
@@ -53,23 +100,26 @@ export const CommcoachDossierView: React.FC = () => {
const sendMessageRef = useRef(coach.sendMessage);
sendMessageRef.current = coach.sendMessage;
- const voice = useVoiceController({
- onFinalText: (text) => sendMessageRef.current(text),
- });
+ const attachedFileIdsRef = useRef(attachedFileIds);
+ attachedFileIdsRef.current = attachedFileIds;
+ const attachedDsIdsRef = useRef(attachedDsIds);
+ attachedDsIdsRef.current = attachedDsIds;
+ const attachedFdsIdsRef = useRef(attachedFdsIds);
+ attachedFdsIdsRef.current = attachedFdsIds;
+ const providerSelRef = useRef(providerSelection);
+ providerSelRef.current = providerSelection;
- // #region agent log
- const debugLogsRef = useRef([]);
- const [debugVisible, setDebugVisible] = useState(false);
- const [debugSnapshot, setDebugSnapshot] = useState([]);
- 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')}`;
- const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
- debugLogsRef.current.push(entry);
- if (debugLogsRef.current.length > 80) debugLogsRef.current.shift();
- }, []);
- useEffect(() => { (window as any).__dlog = _dlog; return () => { delete (window as any).__dlog; }; }, [_dlog]);
- // #endregion
+ const voice = useVoiceController({
+ onFinalText: (text) => {
+ const opts: SendMessageOptions = {};
+ if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
+ if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
+ if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
+ const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
+ if (allowed) opts.allowedProviders = allowed;
+ sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
+ },
+ });
useEffect(() => {
coach.onTtsEventRef.current = (event: TtsEvent) => {
@@ -103,6 +153,23 @@ export const CommcoachDossierView: React.FC = () => {
.catch(() => {});
}, [instanceId, request]);
+ const _refreshWorkspaceAssets = useCallback(() => {
+ if (!instanceId) return;
+ api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
+ api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
+ api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
+ }, [instanceId]);
+
+ useEffect(() => {
+ _refreshWorkspaceAssets();
+ }, [_refreshWorkspaceAssets]);
+
+ useEffect(() => {
+ const _handleFileUploaded = () => _refreshWorkspaceAssets();
+ window.addEventListener('fileUploaded', _handleFileUploaded);
+ return () => window.removeEventListener('fileUploaded', _handleFileUploaded);
+ }, [_refreshWorkspaceAssets]);
+
useEffect(() => {
if (activeTab !== 'coaching' || !coach.session) {
voice.deactivate();
@@ -118,16 +185,44 @@ export const CommcoachDossierView: React.FC = () => {
return () => {
coach.onDocumentCreatedRef.current = null;
};
- }, [coach]);
+ }, [coach, _refreshWorkspaceAssets]);
- const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
+ useEffect(() => {
+ if (coach.agentToolCalls.length > 0) {
+ setShowAgentActivity(true);
+ }
+ }, [coach.agentToolCalls.length]);
+
+ const handleStopTts = useCallback(() => {
+ coach.stopTts();
+ voice.ttsStopped();
+ }, [coach, voice]);
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
- await coach.sendMessage(coach.inputValue);
- }, [coach]);
+ const opts: SendMessageOptions = {};
+ if (attachedFileIds.length) opts.fileIds = attachedFileIds;
+ if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
+ if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
+ const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
+ if (allowed) opts.allowedProviders = allowed;
+ await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
+ setAttachedFileIds([]);
+ setShowSourcePicker(false);
+ setShowFilePicker(false);
+ }, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
+
+ const _toggleFile = useCallback((fileId: string) => {
+ setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
+ }, []);
+ const _toggleDs = useCallback((dsId: string) => {
+ setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
+ }, []);
+ const _toggleFds = useCallback((fdsId: string) => {
+ setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
+ }, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
@@ -379,6 +474,63 @@ export const CommcoachDossierView: React.FC = () => {
+ {(coach.isStreaming || coach.agentToolCalls.length > 0) && (
+
+
setShowAgentActivity(prev => !prev)}
+ type="button"
+ >
+
+ Agent-Aktivität
+ {coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
+
+
+ {coach.streamingStatus || (coach.agentToolCalls.length > 0 ? 'Tool-Aufrufe vorhanden' : 'Warte auf Agent')}
+
+ {showAgentActivity ? '▾' : '▸'}
+
+ {showAgentActivity && (
+
+ {coach.agentToolCalls.length === 0 ? (
+
+ Noch keine Tool-Aufrufe in dieser Antwort.
+
+ ) : (
+ coach.agentToolCalls.map((toolCall, idx) => (
+
+
+ {toolCall.toolName}
+
+ {toolCall.success === true ? 'fertig' : toolCall.success === false ? 'fehler' : 'läuft'}
+
+
+ {toolCall.args && (
+
+ Args: {_formatToolPayload(toolCall.args)}
+
+ )}
+ {toolCall.result && (
+
+ Result: {toolCall.result}
+
+ )}
+
+ ))
+ )}
+
+ )}
+
+ )}
+
{/* Input Area */}
@@ -396,6 +548,53 @@ export const CommcoachDossierView: React.FC = () => {
: 'Mikrofon wird gestartet...'}
+
+ {/* Attachment Chips */}
+ {(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
+
+ {attachedFileIds.map(fId => {
+ const file = wsFiles.find(f => f.id === fId);
+ return (
+
+ {file?.fileName || fId}
+ _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>×
+
+ );
+ })}
+ {attachedDsIds.map(dsId => {
+ const ds = wsDataSources.find(d => d.id === dsId);
+ return (
+
+ {ds?.label || ds?.path || dsId}
+ _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>×
+
+ );
+ })}
+ {attachedFdsIds.map(fdsId => {
+ const fds = wsFeatureDataSources.find(d => d.id === fdsId);
+ return (
+
+ {fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}
+ {fds?.label || fdsId}
+ _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>×
+
+ );
+ })}
+
+ )}
+
);
@@ -596,4 +929,14 @@ function _dimensionLabel(dim: string): string {
return labels[dim] || dim;
}
+function _formatToolPayload(payload: Record
): string {
+ try {
+ const serialized = JSON.stringify(payload);
+ if (!serialized) return '';
+ return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized;
+ } catch {
+ return '[unlesbar]';
+ }
+}
+
export default CommcoachDossierView;
diff --git a/src/pages/views/commcoach/CommcoachKeepAlive.tsx b/src/pages/views/commcoach/CommcoachKeepAlive.tsx
new file mode 100644
index 0000000..cdf19c5
--- /dev/null
+++ b/src/pages/views/commcoach/CommcoachKeepAlive.tsx
@@ -0,0 +1,55 @@
+/**
+ * CommcoachKeepAlive
+ *
+ * Keeps the CommCoach dossier/coaching page mounted across route changes.
+ * Visibility is toggled via CSS so session state, messages, and input state
+ * stay alive when the user leaves and later returns.
+ */
+
+import React, { useRef } from 'react';
+import { useLocation } from 'react-router-dom';
+import { CommcoachDossierView } from './CommcoachDossierView';
+
+const _COMMCOACH_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/(?:coaching|dossier)/;
+
+interface CommcoachKeepAliveProps {
+ isVisible: boolean;
+}
+
+export const CommcoachKeepAlive: React.FC = ({ isVisible }) => {
+ const location = useLocation();
+ const cachedMandateIdRef = useRef('');
+ const cachedInstanceIdRef = useRef('');
+
+ const match = location.pathname.match(_COMMCOACH_ROUTE_RE);
+ if (match?.[1] && match?.[2]) {
+ cachedMandateIdRef.current = match[1];
+ cachedInstanceIdRef.current = match[2];
+ }
+
+ const mandateId = cachedMandateIdRef.current;
+ const instanceId = cachedInstanceIdRef.current;
+ if (!mandateId || !instanceId) return null;
+
+ return (
+
+
+
+ );
+};
+
+export default CommcoachKeepAlive;
diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts
index 5e9e8c8..148bbed 100644
--- a/src/pages/views/commcoach/useVoiceController.ts
+++ b/src/pages/views/commcoach/useVoiceController.ts
@@ -24,6 +24,7 @@ export interface VoiceControllerApi {
ttsPlaying: () => void;
ttsPaused: () => void;
ttsEnded: () => void;
+ ttsStopped: () => void;
toggleMute: () => void;
}
@@ -124,6 +125,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, _startStream, _dlog]);
+ const ttsStopped = useCallback(() => {
+ const cur = stateRef.current;
+ if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
+ voiceStream.stop();
+ if (mutedRef.current) {
+ _setState('interrupted');
+ return;
+ }
+ _setState('listening');
+ _startStream().catch((err) => _dlog('MIC-ERR', String(err)));
+ }, [_setState, _startStream, _dlog, voiceStream]);
+
const toggleMute = useCallback(() => {
const cur = stateRef.current;
if (cur === 'idle') return;
@@ -147,6 +160,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
ttsPlaying,
ttsPaused,
ttsEnded,
+ ttsStopped,
toggleMute,
};
}
From 2a60f322ca0068b6b89565c4719656adad5bd428 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 2 Apr 2026 23:53:24 +0200
Subject: [PATCH 21/23] fix(ui): theme tokens, primary CTAs, billing;
invitations; connections
Made-with: Cursor
---
.../FolderTree/FolderTree.module.css | 6 +-
src/components/UnifiedDataBar/FilesTab.tsx | 8 +-
src/components/UnifiedDataBar/SourcesTab.tsx | 12 +--
src/hooks/useConfirm.tsx | 2 +-
src/hooks/useInvitations.ts | 2 +-
src/hooks/usePrompt.tsx | 2 +-
src/layouts/MainLayout.module.css | 2 +
src/pages/admin/Admin.module.css | 6 +-
.../wizards/AdminInvitationWizardPage.tsx | 79 +++++++++++++------
src/pages/basedata/ConnectionsPage.tsx | 53 ++++++++-----
src/pages/basedata/FilesPage.tsx | 2 +-
src/pages/billing/BillingAdmin.tsx | 2 +-
src/pages/billing/BillingDataView.tsx | 6 +-
src/pages/billing/SubscriptionTab.tsx | 32 ++++----
src/pages/views/workspace/ChatStream.tsx | 12 +--
.../views/workspace/WorkspaceEditorPage.tsx | 2 +-
src/pages/views/workspace/WorkspaceInput.tsx | 12 +--
src/pages/views/workspace/WorkspacePage.tsx | 18 ++---
.../views/workspace/WorkspaceSettingsPage.tsx | 4 +-
src/styles/themes/light.css | 27 +++++--
20 files changed, 175 insertions(+), 114 deletions(-)
diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css
index deab4d3..1530585 100644
--- a/src/components/FolderTree/FolderTree.module.css
+++ b/src/components/FolderTree/FolderTree.module.css
@@ -25,7 +25,7 @@
.treeNode.multiSelected {
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14));
- box-shadow: inset 3px 0 0 var(--color-primary, #1976d2);
+ box-shadow: inset 3px 0 0 var(--color-primary, #F25843);
}
.treeNode.multiSelected:hover {
@@ -34,7 +34,7 @@
.treeNode.dropTarget {
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15));
- outline: 2px dashed var(--color-primary, #1976d2);
+ outline: 2px dashed var(--color-primary, #F25843);
outline-offset: -2px;
}
@@ -77,7 +77,7 @@
.renameInput {
flex: 1;
- border: 1px solid var(--color-primary, #1976d2);
+ border: 1px solid var(--color-primary, #F25843);
border-radius: 3px;
padding: 1px 4px;
font-size: inherit;
diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx
index e38c991..93cbc21 100644
--- a/src/components/UnifiedDataBar/FilesTab.tsx
+++ b/src/components/UnifiedDataBar/FilesTab.tsx
@@ -243,9 +243,9 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => {
Dateien hier ablegen
@@ -257,14 +257,14 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => {
fileInputRef.current?.click()}
disabled={uploading}
- style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title="Upload files"
>
{uploading ? '...' : '+'}
{'\u21BB'}
diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx
index dc9b3f7..b1d1828 100644
--- a/src/components/UnifiedDataBar/SourcesTab.tsx
+++ b/src/components/UnifiedDataBar/SourcesTab.tsx
@@ -142,12 +142,12 @@ const _SOURCE_COLORS: Record = {
ftpFolder: '#795548',
files: '#795548',
'local:ftp': '#795548',
- 'local:jira': '#1976d2',
+ 'local:jira': '#0052CC',
clickup: '#7b68ee',
};
function _getSourceColor(sourceType: string): string {
- return _SOURCE_COLORS[sourceType] || '#1976d2';
+ return _SOURCE_COLORS[sourceType] || '#F25843';
}
const _SOURCE_ICONS: Record = {
@@ -335,7 +335,7 @@ function _Spinner(): React.ReactElement {
return (
@@ -876,7 +876,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged }) =>
{loadingRoot ? '...' : '\u21BB'}
@@ -1157,9 +1157,9 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
onClick={e => { e.stopPropagation(); onAdd(node); }}
disabled={isAdding}
style={{
- background: 'none', border: '1px solid #1976d2', borderRadius: 3,
+ background: 'none', border: '1px solid var(--primary-color, #F25843)', borderRadius: 3,
cursor: isAdding ? 'not-allowed' : 'pointer',
- fontSize: 10, color: '#1976d2', padding: '1px 5px',
+ fontSize: 10, color: 'var(--primary-color, #F25843)', padding: '1px 5px',
opacity: isAdding ? 0.5 : 1,
flexShrink: 0,
}}
diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx
index ec190d7..f246ab4 100644
--- a/src/hooks/useConfirm.tsx
+++ b/src/hooks/useConfirm.tsx
@@ -116,7 +116,7 @@ export function useConfirm() {
style={{
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
border: 'none',
- background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)',
+ background: isDanger ? '#ef4444' : 'var(--primary-color, #F25843)',
color: '#fff',
cursor: 'pointer',
}}
diff --git a/src/hooks/useInvitations.ts b/src/hooks/useInvitations.ts
index 68f505a..caf9d41 100644
--- a/src/hooks/useInvitations.ts
+++ b/src/hooks/useInvitations.ts
@@ -49,7 +49,7 @@ export interface Invitation {
export interface InvitationCreate {
/** Username of the user to invite (optional when email is provided) */
targetUsername?: string;
- /** Email address to send invitation link (required for new users) */
+ /** Email to send invitation link; optional if targetUsername is set */
email?: string;
roleIds: string[];
featureInstanceId?: string;
diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx
index 38218d6..1f08086 100644
--- a/src/hooks/usePrompt.tsx
+++ b/src/hooks/usePrompt.tsx
@@ -144,7 +144,7 @@ export function usePrompt() {
style={{
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
border: 'none',
- background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)',
+ background: isDanger ? '#ef4444' : 'var(--primary-color, #F25843)',
color: '#fff',
cursor: 'pointer',
}}
diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css
index faf2439..1bb1c9a 100644
--- a/src/layouts/MainLayout.module.css
+++ b/src/layouts/MainLayout.module.css
@@ -99,6 +99,7 @@
/* Let child components handle their own scrolling for sticky headers */
overflow: hidden;
background: var(--bg-primary, #ffffff);
+ color: var(--text-primary, #1a1a1a);
}
/* Fills .content flex column so admin pages get a bounded height for inner scroll */
@@ -168,6 +169,7 @@
:global(.dark-theme) .content {
background: var(--bg-dark, #0a0a0a);
+ color: var(--text-primary, #e5e7eb);
}
:global(.dark-theme) .mobileMenuButton {
diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css
index 00af2c7..bf7c98b 100644
--- a/src/pages/admin/Admin.module.css
+++ b/src/pages/admin/Admin.module.css
@@ -550,8 +550,8 @@
.statusBadge.starting,
.statusBadge.running {
- background: #e3f2fd;
- color: #1976d2;
+ background: var(--primary-dark-bg, rgba(242, 88, 67, 0.12));
+ color: var(--primary-color, #F25843);
}
.statusBadge.completed {
@@ -617,7 +617,7 @@
}
.logStatus {
- color: #1976d2;
+ color: var(--primary-color, #F25843);
}
.logEntryError .logStatus,
diff --git a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx
index fffc5b5..392706b 100644
--- a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx
+++ b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx
@@ -4,7 +4,7 @@
* 4-step invitation wizard:
* 1. Choose invite type: "Invite to mandate" OR "Invite to feature instance"
* 2. Select mandate (and feature instance if applicable)
- * 3. Add invitees (email required, username optional; existing users; role per invitee)
+ * 3. Add invitees (mindestens E-Mail oder Benutzername für neue Benutzer; bestehende Benutzer; Rolle pro Einladung)
* 4. Summary and send
*/
@@ -167,13 +167,24 @@ export const AdminInvitationWizardPage: React.FC = () => {
const addInviteeByEmail = () => {
const email = inviteeForm.email.trim();
- if (!email) {
- setError('E-Mail ist erforderlich');
+ const username = inviteeForm.username.trim();
+ if (!email && !username) {
+ setError('Bitte mindestens eine E-Mail-Adresse oder einen Benutzernamen angeben.');
+ return;
+ }
+ const emailLower = email.toLowerCase();
+ const userLower = username.toLowerCase();
+ if (email && invitees.some(i => !i.isExisting && (i.email || '').toLowerCase() === emailLower)) {
+ setError('Diese E-Mail ist bereits in der Liste');
+ return;
+ }
+ if (username && invitees.some(i => !i.isExisting && (i.username || '').toLowerCase() === userLower)) {
+ setError('Dieser Benutzername ist bereits in der Liste');
return;
}
setInvitees(prev => [...prev, {
email,
- username: undefined,
+ username: username || undefined,
roleIds: [...inviteeForm.roleIds],
isExisting: false,
}]);
@@ -189,10 +200,6 @@ export const AdminInvitationWizardPage: React.FC = () => {
const user = allSystemUsers.find(u => u.id === selectedExistingUserId);
if (!user) return;
const email = (user.email || '').trim();
- if (!email) {
- setError('Dieser Benutzer hat keine E-Mail-Adresse');
- return;
- }
if (invitees.some(i => i.userId === user.id)) {
setError('Dieser Benutzer ist bereits in der Liste');
return;
@@ -232,8 +239,9 @@ export const AdminInvitationWizardPage: React.FC = () => {
const results: DispatchResult[] = [];
try {
for (const inv of invitees) {
+ const emailTrim = (inv.email || '').trim();
const payload = {
- email: inv.email,
+ ...(emailTrim ? { email: emailTrim } : {}),
targetUsername: inv.username || undefined,
roleIds: inv.roleIds,
expiresInHours: EXPIRES_IN_HOURS,
@@ -244,14 +252,14 @@ export const AdminInvitationWizardPage: React.FC = () => {
const result = await createInvitation(selectedMandate.id, payload);
if (result.success) {
results.push({
- email: inv.email,
+ email: emailTrim,
username: inv.username,
success: true,
emailSent: result.data?.emailSent,
});
} else {
results.push({
- email: inv.email,
+ email: emailTrim,
username: inv.username,
success: false,
error: result.error,
@@ -452,7 +460,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
Einladungen hinzufügen
- E-Mail ist erforderlich. Neue Benutzer legen ihren Benutzernamen beim Annehmen der Einladung selbst fest. Sie können neue Benutzer per E-Mail oder bestehende Benutzer hinzufügen.
+ Für neue Benutzer: mindestens eine E-Mail oder ein Benutzername (vorgegeben). Ohne E-Mail wird kein Link per Mail versendet — der Einladungslink kann manuell geteilt werden. Bestehende Benutzer wählen Sie im zweiten Tab.
{/* Add form: toggle email vs existing */}
@@ -462,7 +470,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
style={{ fontSize: '12px', padding: '6px 12px' }}
onClick={() => setAddMode('email')}
>
- Per E-Mail (neue Benutzer)
+ Neue Benutzer (E-Mail und/oder Benutzername)
{
{addMode === 'email' ? (
- E-Mail *
+ E-Mail (optional)
{
onChange={e => setInviteeForm(p => ({ ...p, email: e.target.value }))}
placeholder="beispiel@firma.com"
/>
+
+
+
Benutzername (optional)
+
setInviteeForm(p => ({ ...p, username: e.target.value }))}
+ placeholder="z. B. vorname.nachname"
+ />
- Der Benutzername wird vom eingeladenen Benutzer beim Annehmen der Einladung festgelegt.
+ Mindestens eines der beiden Felder ausfüllen. Mit Benutzername muss der Eingeladene genau diesen Namen beim Annehmen verwenden.
{roles.length > 0 && (
@@ -497,7 +516,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
display: 'flex', alignItems: 'center', gap: '6px',
padding: '6px 12px', borderRadius: '6px',
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
- border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #3b82f6)' : 'var(--border-color, #e2e8f0)'}`,
+ border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #F25843)' : 'var(--border-color, #e2e8f0)'}`,
fontSize: '12px', cursor: 'pointer',
}}>
{
0 && inviteeForm.roleIds.length === 0)}
+ disabled={
+ (!inviteeForm.email.trim() && !inviteeForm.username.trim())
+ || (roles.length > 0 && inviteeForm.roleIds.length === 0)
+ }
>
Hinzufügen
@@ -552,7 +574,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
display: 'flex', alignItems: 'center', gap: '6px',
padding: '6px 12px', borderRadius: '6px',
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
- border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #3b82f6)' : 'var(--border-color, #e2e8f0)'}`,
+ border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #F25843)' : 'var(--border-color, #e2e8f0)'}`,
fontSize: '12px', cursor: 'pointer',
}}>
{
- E-Mail
+ E-Mail / Benutzer
Benutzername
Rollen
Typ
@@ -596,14 +618,16 @@ export const AdminInvitationWizardPage: React.FC = () => {
{invitees.map((inv, idx) => (
- {inv.email}
- {inv.isExisting ? inv.username : ''}
+ {inv.email || '—'}
+
+ {inv.username || ''}
+
{inv.roleIds.length > 0
? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')
: '-'}
- {inv.isExisting ? 'Bestehend' : 'Neu'}
+ {inv.isExisting ? 'Bestehend' : 'Neu (Einladung)'}
removeInvitee(idx)}
@@ -654,7 +678,8 @@ export const AdminInvitationWizardPage: React.FC = () => {
{invitees.map((inv, i) => (
- {inv.email}{inv.isExisting && inv.username ? ` (${inv.username})` : ''}
+ {[inv.email || null, inv.username ? `@${inv.username}` : null].filter(Boolean).join(' · ')
+ || '—'}
{inv.roleIds.length > 0 && ` – ${roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')}`}
))}
@@ -680,7 +705,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
- E-Mail
+ E-Mail / Benutzer
Status
E-Mail gesendet
@@ -688,7 +713,11 @@ export const AdminInvitationWizardPage: React.FC = () => {
{dispatchResults.map((r, idx) => (
- {r.email}{r.username ? ` (${r.username})` : ''}
+
+ {(r.email || '').trim() && r.username
+ ? `${(r.email || '').trim()} (@${r.username})`
+ : (r.email || '').trim() || (r.username ? `@${r.username}` : '—')}
+
{
// Use the consolidated hook
const {
@@ -190,8 +193,9 @@ export const ConnectionsPage: React.FC = () => {
}
};
- // Handle create ClickUp connection
+ // Handle create ClickUp connection (UI kann per Flag abgeschaltet sein)
const handleCreateClickup = async () => {
+ if (!isClickupConnectionUiEnabled) return;
try {
await createClickupConnectionAndAuth();
refetch();
@@ -245,7 +249,8 @@ export const ConnectionsPage: React.FC = () => {
Verbindungen
- Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)
+ Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft
+ {isClickupConnectionUiEnabled ? ', ClickUp' : ''})
@@ -280,14 +285,17 @@ export const ConnectionsPage: React.FC = () => {
>
Microsoft
-
- ClickUp
-
+ {isClickupConnectionUiEnabled && (
+
+ ClickUp
+
+ )}
>
)}
@@ -304,7 +312,9 @@ export const ConnectionsPage: React.FC = () => {
Keine Verbindungen vorhanden
- Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.
+ {isClickupConnectionUiEnabled
+ ? 'Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.'
+ : 'Verbinden Sie Ihr Google- oder Microsoft-Konto, um loszulegen.'}
{canCreate && (
@@ -322,13 +332,16 @@ export const ConnectionsPage: React.FC = () => {
>
Mit Microsoft verbinden
-
- Mit ClickUp verbinden
-
+ {isClickupConnectionUiEnabled && (
+
+ Mit ClickUp verbinden
+
+ )}
)}
@@ -362,7 +375,9 @@ export const ConnectionsPage: React.FC = () => {
icon: ,
onClick: handleConnect,
title: 'Verbinden',
- visible: (row: Connection) => row.status !== 'active',
+ visible: (row: Connection) =>
+ row.status !== 'active' &&
+ (isClickupConnectionUiEnabled || row.authority !== 'clickup'),
loading: () => isConnecting,
},
{
diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx
index fe984e9..c315aac 100644
--- a/src/pages/basedata/FilesPage.tsx
+++ b/src/pages/basedata/FilesPage.tsx
@@ -381,7 +381,7 @@ export const FilesPage: React.FC = () => {
style={{
width: 6,
cursor: 'col-resize',
- background: isDragging ? 'var(--color-primary, #1976d2)' : 'transparent',
+ background: isDragging ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.2))' : 'transparent',
transition: isDragging ? 'none' : 'background 0.15s',
flexShrink: 0,
zIndex: 10,
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx
index 6d72866..01f8039 100644
--- a/src/pages/billing/BillingAdmin.tsx
+++ b/src/pages/billing/BillingAdmin.tsx
@@ -676,7 +676,7 @@ export const BillingAdmin: React.FC = () => {
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
- backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
+ backgroundColor: isActive ? 'var(--primary-color, #F25843)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
fontWeight: isActive ? 600 : 400,
cursor: 'pointer',
diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx
index 65501a0..81172eb 100644
--- a/src/pages/billing/BillingDataView.tsx
+++ b/src/pages/billing/BillingDataView.tsx
@@ -128,7 +128,7 @@ const TabNav: React.FC = ({ activeTab, onTabChange }) => {
padding: '8px 16px',
textDecoration: 'none',
borderRadius: '4px',
- backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
+ backgroundColor: isActive ? 'var(--primary-color, #F25843)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
fontWeight: isActive ? 600 : 400,
cursor: 'pointer',
@@ -646,7 +646,7 @@ export const BillingDataView: React.FC = () => {
? 'var(--color-error, #ef4444)'
: pct >= 70
? 'var(--color-warning, #f59e0b)'
- : 'var(--primary-color, #3b82f6)';
+ : 'var(--primary-color, #F25843)';
return (
{sv.mandateName}
@@ -726,7 +726,7 @@ export const BillingDataView: React.FC = () => {
? 'var(--color-error, #ef4444)'
: pct >= 70
? 'var(--color-warning, #f59e0b)'
- : 'var(--primary-color, #3b82f6)';
+ : 'var(--primary-color, #F25843)';
return (
= {
PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' },
SCHEDULED: { label: 'Geplant', color: '#8b5cf6' },
ACTIVE: { label: 'Aktiv', color: '#22c55e' },
- TRIALING: { label: 'Testphase', color: '#3b82f6' },
+ TRIALING: { label: 'Testphase', color: '#38bdf8' },
PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' },
EXPIRED: { label: 'Abgelaufen', color: '#6b7280' },
};
@@ -75,13 +75,13 @@ const PlanCard: React.FC
= ({ plan, isCurrent, onActivate, activa
return (
@@ -89,12 +89,12 @@ const PlanCard: React.FC
= ({ plan, isCurrent, onActivate, activa
{isCurrent && (
Aktuell
)}
-
+
{_t(plan.description)}
@@ -102,7 +102,7 @@ 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.):{' '}
@@ -112,7 +112,7 @@ const PlanCard: React.FC
= ({ plan, isCurrent, onActivate, activa
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
-
+
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
@@ -140,7 +140,7 @@ const PlanCard: React.FC
= ({ plan, isCurrent, onActivate, activa
disabled={!!activatingPlanKey}
style={{
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
- background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
+ background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
cursor: activatingPlanKey ? 'wait' : 'pointer',
opacity: activatingPlanKey ? 0.6 : 1,
}}
@@ -177,15 +177,15 @@ const SubInfoCard: React.FC = ({ sub, plan, label, onCancel, onRea
return (
-
+
{label}
@@ -229,7 +229,7 @@ const SubInfoCard: React.FC
= ({ sub, plan, label, onCancel, onRea
{!isPending && !isScheduled && (
Gestartet: {_formatDate(sub.startedAt)}
@@ -263,7 +263,7 @@ const SubInfoCard: React.FC
= ({ sub, plan, label, onCancel, onRea
disabled={reactivating}
style={{
padding: '6px 14px', borderRadius: '6px', border: 'none',
- background: 'var(--color-primary, #3b82f6)', color: '#fff',
+ background: 'var(--primary-color, #F25843)', color: '#fff',
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
}}
>
@@ -443,9 +443,9 @@ export const SubscriptionTab: React.FC = ({ mandateId }) =
{checkoutMessage && (
{checkoutMessage.text}
diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx
index a66bc3b..7f7f8cb 100644
--- a/src/pages/views/workspace/ChatStream.tsx
+++ b/src/pages/views/workspace/ChatStream.tsx
@@ -125,7 +125,7 @@ export const ChatStream: React.FC = ({
),
a: ({ href, children }) => (
-
+
{children}
),
@@ -222,7 +222,7 @@ export const ChatStream: React.FC = ({
onClick={onOpenEditor}
style={{
padding: '5px 14px', borderRadius: 4, border: 'none',
- background: 'var(--primary-color, #1976d2)', color: '#fff',
+ background: 'var(--primary-color, #F25843)', color: '#fff',
cursor: 'pointer', fontSize: 12, fontWeight: 600,
}}
>
@@ -280,7 +280,7 @@ export const ChatStream: React.FC = ({
}}>
Processing...
@@ -373,7 +373,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
{ext.toUpperCase()}{sizeLabel ? ` \u00b7 ${sizeLabel}` : ''}
- ⬇
+ ⬇
);
}
@@ -455,7 +455,7 @@ function _AudioPlayer({ url, language }: { url: string; language?: string; charC
onClick={_togglePlay}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none',
- background: 'var(--primary-color, #1976d2)', color: '#fff',
+ background: 'var(--primary-color, #F25843)', color: '#fff',
cursor: 'pointer', fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
@@ -473,7 +473,7 @@ function _AudioPlayer({ url, language }: { url: string; language?: string; charC
}}>
diff --git a/src/pages/views/workspace/WorkspaceEditorPage.tsx b/src/pages/views/workspace/WorkspaceEditorPage.tsx
index 7629520..83cf9ee 100644
--- a/src/pages/views/workspace/WorkspaceEditorPage.tsx
+++ b/src/pages/views/workspace/WorkspaceEditorPage.tsx
@@ -236,7 +236,7 @@ const _EditorTab: React.FC<{
padding: '6px 16px',
fontSize: 13,
border: 'none',
- borderBottom: isActive ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent',
+ borderBottom: isActive ? '2px solid var(--primary-color, #F25843)' : '2px solid transparent',
background: isActive ? 'var(--bg-primary, #fff)' : 'transparent',
cursor: 'pointer',
whiteSpace: 'nowrap',
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx
index affed12..a2a3a22 100644
--- a/src/pages/views/workspace/WorkspaceInput.tsx
+++ b/src/pages/views/workspace/WorkspaceInput.tsx
@@ -324,8 +324,8 @@ export const WorkspaceInput: React.FC = ({
borderTop: '1px solid var(--border-color, #e0e0e0)',
position: 'relative',
flexShrink: 0,
- outline: treeDropOver ? '2px dashed #1976d2' : 'none',
- background: treeDropOver ? 'rgba(25, 118, 210, 0.04)' : undefined,
+ outline: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : 'none',
+ background: treeDropOver ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.08))' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handlePromptDragOver}
@@ -534,7 +534,7 @@ export const WorkspaceInput: React.FC = ({
style={{
width: _controlSize, height: _controlSize, borderRadius: 8, border: '1px solid var(--border-color, #ddd)',
background: 'var(--secondary-bg, #f5f5f5)',
- color: uploading ? '#1976d2' : '#666',
+ color: uploading ? 'var(--primary-color, #F25843)' : 'var(--text-secondary, #666)',
cursor: uploading || isProcessing ? 'not-allowed' : 'pointer',
fontSize: 16, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: isProcessing ? 0.5 : 1,
@@ -706,10 +706,10 @@ export const WorkspaceInput: React.FC = ({
}}
style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
- background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
+ background: lang.code === voiceLanguage ? 'var(--primary-color, #F25843)' : 'transparent',
color: lang.code === voiceLanguage ? '#fff' : 'var(--text-primary, #333)',
}}
- onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = '#f5f5f5'; }}
+ onMouseEnter={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = 'var(--bg-secondary, #f5f5f5)'; }}
onMouseLeave={e => { if (lang.code !== voiceLanguage) e.currentTarget.style.background = ''; }}
>
{lang.label} ({lang.code})
@@ -751,7 +751,7 @@ export const WorkspaceInput: React.FC = ({
disabled={!prompt.trim()}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
- background: prompt.trim() ? 'var(--primary-color, #1976d2)' : '#ccc',
+ background: prompt.trim() ? 'var(--primary-color, #F25843)' : 'var(--color-gray-disabled, #ccc)',
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600,
minWidth: isMobile ? 84 : undefined,
}}
diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx
index 9a11ed9..84abd12 100644
--- a/src/pages/views/workspace/WorkspacePage.tsx
+++ b/src/pages/views/workspace/WorkspacePage.tsx
@@ -220,12 +220,12 @@ export const WorkspacePage: React.FC = ({ persistentInstance
flex: 1,
padding: '6px 0',
border: 'none',
- borderBottom: active ? '2px solid var(--primary-color, #1976d2)' : '2px solid transparent',
+ borderBottom: active ? '2px solid var(--primary-color, #F25843)' : '2px solid transparent',
background: 'none',
cursor: 'pointer',
fontSize: 11,
fontWeight: active ? 600 : 400,
- color: active ? 'var(--primary-color, #1976d2)' : '#888',
+ color: active ? 'var(--primary-color, #F25843)' : 'var(--text-tertiary, #888)',
textTransform: 'uppercase' as const,
});
@@ -326,7 +326,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance
_leftResize.onMouseDown(e, 1)}
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
- onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
+ onMouseEnter={e => (e.currentTarget.style.background = 'var(--primary-color, #F25843)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
/>
)}
@@ -377,7 +377,7 @@ export const WorkspacePage: React.FC
= ({ persistentInstance
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
- background: rightTab === 'activity' ? '#e8f3ff' : '#f7f7f7',
+ background: rightTab === 'activity' ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))' : '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
}}
@@ -390,7 +390,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
- background: rightTab === 'preview' ? '#e8f3ff' : '#f7f7f7',
+ background: rightTab === 'preview' ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))' : '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
}}
@@ -402,10 +402,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance
{isDragOver && (
Dateien hier ablegen
@@ -452,7 +452,7 @@ export const WorkspacePage: React.FC
= ({ persistentInstance
_rightResize.onMouseDown(e, -1)}
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
- onMouseEnter={e => (e.currentTarget.style.background = '#1976d2')}
+ onMouseEnter={e => (e.currentTarget.style.background = 'var(--primary-color, #F25843)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
/>
)}
diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx
index 644f253..60cef32 100644
--- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx
+++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx
@@ -46,14 +46,14 @@ export const WorkspaceSettingsPage: React.FC = () => {
padding: '10px 20px',
border: 'none',
borderBottom: activeTab === tab.key
- ? '2px solid var(--primary-color, #1976d2)'
+ ? '2px solid var(--primary-color, #F25843)'
: '2px solid transparent',
background: 'none',
cursor: 'pointer',
fontSize: 14,
fontWeight: activeTab === tab.key ? 600 : 400,
color: activeTab === tab.key
- ? 'var(--primary-color, #1976d2)'
+ ? 'var(--primary-color, #F25843)'
: 'var(--text-secondary, #888)',
}}
>
diff --git a/src/styles/themes/light.css b/src/styles/themes/light.css
index cb113f9..80deceb 100644
--- a/src/styles/themes/light.css
+++ b/src/styles/themes/light.css
@@ -82,6 +82,15 @@
/* Error color */
--error-color: #dc2626;
+
+ /* Legacy / inline-style aliases (override :root beige --color-primary) */
+ --color-primary: var(--primary-color);
+ --color-primary-hover: var(--primary-color-dark);
+ --color-primary-disabled: var(--primary-color-light);
+ --color-border: var(--border-color);
+ --bg-card: var(--bg-primary);
+ --bg-input: #ffffff;
+ --bg-hover: var(--hover-bg);
}
/* ============================================== */
@@ -92,9 +101,9 @@
--color-surface: #1E1D1A;
--color-text: #E5E7EB;
- --color-primary: #C7C5B2;
- --color-primary-hover: #E0DECC;
- --color-primary-disabled: #59584F;
+ --color-primary: var(--primary-color);
+ --color-primary-hover: var(--primary-color-dark);
+ --color-primary-disabled: rgba(242, 88, 67, 0.35);
--color-secondary: #F25843;
--color-secondary-hover: #FF715C;
@@ -108,9 +117,10 @@
--color-secondary-red-hover: #E17683;
--color-secondary-red-disabled: #70363C;
- --color-gray: #181818;
- --color-gray-hover: #2E2E2E;
- --color-gray-disabled: #505050;
+ /* Readable neutrals on dark (was #181818 — same as bg, illegible) */
+ --color-gray: #9ca3af;
+ --color-gray-hover: #d1d5db;
+ --color-gray-disabled: #57534e;
/* Background colors */
--bg-primary: #181818;
@@ -146,4 +156,9 @@
/* Error color */
--error-color: #ef4444;
+
+ --color-border: var(--border-color);
+ --bg-card: #252422;
+ --bg-input: #2c2926;
+ --bg-hover: rgba(255, 255, 255, 0.08);
}
From 892603156c2d5f11b19255d1341ba81fa25214ee Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sat, 4 Apr 2026 16:46:22 +0200
Subject: [PATCH 22/23] core class for system attributes sysCreated /
sysModified
---
src/api/automation2Api.ts | 4 ++--
src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/api/automation2Api.ts b/src/api/automation2Api.ts
index 03ac622..d7d7641 100644
--- a/src/api/automation2Api.ts
+++ b/src/api/automation2Api.ts
@@ -281,8 +281,8 @@ export async function fetchWorkflowRuns(
export interface CompletedRun extends Automation2Run {
workflowLabel?: string;
- _modifiedAt?: number;
- _createdAt?: number;
+ sysModifiedAt?: number;
+ sysCreatedAt?: number;
}
export async function fetchCompletedRuns(
diff --git a/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
index 8ade65c..45c763c 100644
--- a/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
+++ b/src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
@@ -337,7 +337,7 @@ const OutputCard: React.FC<{
run: CompletedRun;
instanceId?: string;
}> = ({ run }) => {
- const ts = run._modifiedAt ?? run._createdAt ?? 0;
+ const ts = run.sysModifiedAt ?? run.sysCreatedAt ?? 0;
const files: Array<{ name: string; fileId: string }> = [];
const nodeOutputs = run.nodeOutputs ?? {};
for (const [, out] of Object.entries(nodeOutputs)) {
From 1d0cb96a91040f9bdc6719828e2a2d287f86c622 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sat, 4 Apr 2026 23:24:59 +0200
Subject: [PATCH 23/23] added formgenerator filter query parameter to pass with
url
---
.../FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx | 4 +++-
src/pages/Login.tsx | 4 ++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
index 351a59b..f53f0a2 100644
--- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
+++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
@@ -226,6 +226,7 @@ export interface FormGeneratorTableProps {
groupRowData?: (groupKey: string, groupRows: T[]) => Record;
groupDefaultExpanded?: boolean;
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
+ initialSearchTerm?: string;
rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent, row: T) => void;
}
@@ -327,6 +328,7 @@ export function FormGeneratorTable>({
groupRowData,
groupDefaultExpanded = true,
groupActions,
+ initialSearchTerm = '',
rowDraggable = false,
onRowDragStart,
}: FormGeneratorTableProps) {
@@ -368,7 +370,7 @@ export function FormGeneratorTable>({
}, [providedColumns, data]);
// State management
- const [searchTerm, setSearchTerm] = useState('');
+ const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
const [searchFocused, setSearchFocused] = useState(false);
const [filterFocused, setFilterFocused] = useState>({});
// Multi-column sorting: array of sort configs in order of priority
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index 4ac295f..91dad94 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -28,8 +28,8 @@ function Login() {
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken;
- // Get the page the user was trying to visit
- const from = location.state?.from?.pathname || "/";
+ const fromLocation = location.state?.from;
+ const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
// Set page title and generate CSRF token
useEffect(() => {