Merge branch 'int' into feat/grafical-workflow-editor

This commit is contained in:
Patrick Motsch 2026-04-05 00:42:01 +02:00 committed by GitHub
commit a2f9e813aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
125 changed files with 7196 additions and 3680 deletions

View file

@ -27,6 +27,8 @@ export interface RegisterData {
language?: string;
enabled?: boolean;
privilege?: string;
registrationType?: 'personal' | 'company';
companyName?: string;
}
export interface RegisterRequest {
@ -40,6 +42,8 @@ export interface RegisterRequest {
authenticationAuthority: string;
};
frontendUrl: string;
registrationType?: string;
companyName?: string;
}
export interface PasswordResetRequestResponse {
@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise<RegisterR
privilege: registerData.privilege || 'user',
authenticationAuthority: 'local'
},
frontendUrl: window.location.origin
frontendUrl: window.location.origin,
registrationType: registerData.registrationType,
companyName: registerData.companyName,
};
// Prepare headers with CSRF token if available

View file

@ -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(
@ -313,7 +313,7 @@ export interface Automation2Task {
result?: Record<string, unknown>;
/** Workflow label (enriched by API) */
workflowLabel?: string;
/** Unix timestamp ms (from _createdAt) */
/** Unix timestamp ms (from sysCreatedAt) */
createdAt?: number;
/** Optional due date - configurable in future */
dueAt?: number;

View file

@ -18,9 +18,9 @@ export interface Automation {
nextExecution?: number;
executionLogs?: AutomationLog[];
allowedProviders?: string[];
_createdAt?: number;
sysCreatedAt?: number;
_updatedAt?: number;
_createdByUserName?: string;
sysCreatedByUserName?: string;
mandateName?: string;
featureInstanceName?: string;
[key: string]: any;
@ -48,9 +48,9 @@ export interface AutomationTemplate {
label: TextMultilingual;
overview?: TextMultilingual;
template: string; // JSON string with {{KEY:...}} placeholders
_createdAt?: number;
_createdBy?: string;
_createdByUserName?: string;
sysCreatedAt?: number;
sysCreatedBy?: string;
sysCreatedByUserName?: string;
}
// Workflow action definition from backend
@ -301,7 +301,7 @@ export async function fetchAutomationTemplateById(
*/
export async function createAutomationTemplateApi(
request: ApiRequestFunction,
templateData: Omit<AutomationTemplate, 'id' | '_createdAt' | '_createdBy'>
templateData: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>
): Promise<AutomationTemplate> {
return await request({
url: '/api/automation-templates',

View file

@ -4,14 +4,12 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES
// ============================================================================
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER';
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION';
export interface BillingBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
balance: number;
currency: string;
warningThreshold: number;
@ -41,19 +39,21 @@ export interface BillingTransaction {
export interface BillingSettings {
id: string;
mandateId: string;
billingModel: BillingModel;
defaultUserCredit: number;
warningThresholdPercent: number;
notifyOnWarning: boolean;
notifyEmails: string[];
autoRechargeEnabled?: boolean;
rechargeAmountCHF?: number;
rechargeMaxPerMonth?: number;
}
export interface BillingSettingsUpdate {
billingModel?: BillingModel;
defaultUserCredit?: number;
warningThresholdPercent?: number;
notifyOnWarning?: boolean;
notifyEmails?: string[];
autoRechargeEnabled?: boolean;
rechargeAmountCHF?: number;
rechargeMaxPerMonth?: number;
}
export interface UsageReport {
@ -69,7 +69,6 @@ export interface AccountSummary {
id: string;
mandateId: string;
userId?: string;
accountType: string;
balance: number;
warningThreshold: number;
enabled: boolean;
@ -305,10 +304,8 @@ export async function fetchUsersForMandateAdmin(
export interface MandateBalance {
mandateId: string;
mandateName: string;
billingModel: BillingModel;
totalBalance: number;
userCount: number;
defaultUserCredit: number;
warningThresholdPercent: number;
}

View file

@ -50,18 +50,6 @@ export interface CoachingPersona {
isActive: boolean;
}
export interface CoachingDocument {
id: string;
contextId: string;
fileName: string;
mimeType: string;
fileSize: number;
extractedText?: string;
summary?: string;
fileRef?: string;
createdAt?: string;
}
export interface CoachingBadge {
id: string;
userId: string;
@ -110,8 +98,6 @@ export interface CoachingScore {
export interface CoachingUserProfile {
id: string;
userId: string;
preferredLanguage: string;
preferredVoice?: string;
dailyReminderTime?: string;
dailyReminderEnabled: boolean;
emailSummaryEnabled: boolean;
@ -299,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,
@ -307,6 +300,7 @@ export async function sendMessageStreamApi(
onError?: (error: Error) => void,
onComplete?: () => void,
signal?: AbortSignal,
options?: SendMessageOptions,
): Promise<void> {
try {
const baseURL = api.defaults.baseURL || '';
@ -318,10 +312,16 @@ export async function sendMessageStreamApi(
if (!getCSRFToken()) generateAndStoreCSRFToken();
addCSRFTokenToHeaders(headers);
const body: Record<string, unknown> = { 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,
});
@ -494,27 +494,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
return data.profile;
}
// ============================================================================
// Voice API
// ============================================================================
export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' });
return data.languages || [];
}
export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise<any[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } });
return data.voices || [];
}
export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: {
text?: string; language?: string; voiceId?: string;
}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> {
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
return data;
}
// ============================================================================
// Persona API (Iteration 2)
// ============================================================================
@ -535,42 +514,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId:
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
}
// ============================================================================
// Document API (Iteration 2)
// ============================================================================
export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingDocument[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' });
return data.documents || [];
}
export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise<CoachingDocument> {
const baseURL = api.defaults.baseURL || '';
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`;
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
const authToken = localStorage.getItem('authToken');
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
if (pathMatch) {
headers['X-Mandate-Id'] = pathMatch[1];
headers['X-Instance-Id'] = pathMatch[3];
}
if (!getCSRFToken()) generateAndStoreCSRFToken();
addCSRFTokenToHeaders(headers);
const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' });
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
const data = await response.json();
return data.document;
}
export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise<void> {
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
}
// ============================================================================
// Badge API (Iteration 2)
// ============================================================================

View file

@ -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<void> {
await request({
url: `/api/mandates/${mandateId}`,
method: 'delete',
params: { force: true },
additionalConfig: {
headers: { 'X-Confirm-Name': confirmName }
}
});
}

View file

@ -9,7 +9,7 @@ export interface Prompt {
mandateId: string;
content: string;
name: string;
_createdBy?: string;
sysCreatedBy?: string;
_hideDelete?: boolean;
[key: string]: any; // Allow additional properties
}

View file

@ -23,8 +23,8 @@ export interface RealEstateProject {
featureInstanceId?: string;
perimeter?: any;
parzellen?: RealEstateParcel[];
_createdAt?: number;
_modifiedAt?: number;
sysCreatedAt?: number;
sysModifiedAt?: number;
[key: string]: any;
}
@ -38,8 +38,8 @@ export interface RealEstateParcel {
plz?: string;
perimeter?: any;
bauzone?: string;
_createdAt?: number;
_modifiedAt?: number;
sysCreatedAt?: number;
sysModifiedAt?: number;
[key: string]: any;
}

View file

@ -7,14 +7,21 @@
import api from '../api';
export interface StoreFeatureInstance {
instanceId: string;
mandateId: string;
mandateName: string;
label: string;
isActive: boolean;
}
export interface StoreFeature {
featureCode: string;
label: Record<string, string>;
icon: string;
description: Record<string, string>;
isActive: boolean;
instances: StoreFeatureInstance[];
canActivate: boolean;
instanceId: string | null;
}
export interface StoreActivateResponse {
@ -31,17 +38,44 @@ export interface StoreDeactivateResponse {
deactivated: boolean;
}
export interface UserMandate {
id: string;
name: string;
label: string;
}
export interface SubscriptionInfo {
plan: string | null;
status: string | null;
maxDataVolumeMB: number | null;
maxFeatureInstances: number | null;
budgetAiCHF: number | null;
currentFeatureInstances: number;
trialEndsAt: string | null;
}
export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
const response = await api.get<StoreFeature[]>('/api/store/features');
return response.data;
}
export async function activateStoreFeature(featureCode: string): Promise<StoreActivateResponse> {
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode });
export async function fetchUserMandates(): Promise<UserMandate[]> {
const response = await api.get<UserMandate[]>('/api/store/mandates');
return response.data;
}
export async function deactivateStoreFeature(featureCode: string): Promise<StoreDeactivateResponse> {
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode });
export async function fetchSubscriptionInfo(mandateId?: string): Promise<SubscriptionInfo> {
const params = mandateId ? { mandateId } : {};
const response = await api.get<SubscriptionInfo>('/api/store/subscription-info', { params });
return response.data;
}
export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise<StoreActivateResponse> {
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode, mandateId });
return response.data;
}
export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise<StoreDeactivateResponse> {
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode, mandateId, instanceId });
return response.data;
}

View file

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

View file

@ -18,10 +18,10 @@ export interface TrusteeOrganisation {
label: string;
enabled: boolean;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
sysCreatedBy?: string;
sysModifiedBy?: string;
[key: string]: any;
}
@ -29,10 +29,10 @@ export interface TrusteeRole {
id: string;
desc: string;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
sysCreatedBy?: string;
sysModifiedBy?: string;
[key: string]: any;
}
@ -43,10 +43,10 @@ export interface TrusteeAccess {
userId: string;
contractId?: string | null;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
sysCreatedBy?: string;
sysModifiedBy?: string;
[key: string]: any;
}
@ -56,10 +56,10 @@ export interface TrusteeContract {
label: string;
enabled: boolean;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
sysCreatedBy?: string;
sysModifiedBy?: string;
[key: string]: any;
}
@ -71,10 +71,10 @@ export interface TrusteeDocument {
documentMimeType: string;
documentData?: any;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
sysCreatedBy?: string;
sysModifiedBy?: string;
[key: string]: any;
}
@ -98,10 +98,10 @@ export interface TrusteePosition {
costCenter?: string;
bookingReference?: string;
mandateId?: string;
_createdAt?: number;
_modifiedAt?: number;
_createdBy?: string;
_modifiedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
sysCreatedBy?: string;
sysModifiedBy?: string;
[key: string]: any;
}
@ -696,8 +696,8 @@ export interface TrusteePositionDocument {
documentId: string;
mandateId?: string;
featureInstanceId?: string;
_createdAt?: number;
_modifiedAt?: number;
sysCreatedAt?: number;
sysModifiedAt?: number;
[key: string]: any;
}

View file

@ -35,6 +35,7 @@ import {
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import styles from './Automation2FlowEditor.module.css';
const LOG = '[Automation2]';
@ -55,6 +56,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
initialWorkflowId,
}) => {
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [loading, setLoading] = useState(true);
@ -140,8 +142,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse);
} else {
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
const created = await createWorkflow(request, instanceId, { label, graph, invocations });
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,
invocations,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]);
@ -152,7 +166,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]);
const handleLoad = useCallback(
async (workflowId: string) => {
@ -436,7 +450,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
)}
</div>
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}

View file

@ -225,9 +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' },
...(statusTriggerRow ? [statusTriggerRow] : []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number' },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
@ -252,8 +263,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?.name) {
customRefs[fid] = createRef(formNodeId, ['payload', inf.name]);
const payloadKey = inf?.name;
if (fid && payloadKey) {
customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]);
}
}

View file

@ -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<AutomationEditorProps> = ({
const [label, setLabel] = useState('');
const [schedule, setSchedule] = useState('0 22 * * *');
const [active, setActive] = useState(false);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders: billingProviders } = useBilling();
// Template multilingual fields
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
@ -537,7 +540,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
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<AutomationEditorProps> = ({
active,
template: templateJson,
placeholders,
allowedProviders
allowedProviders: _toBackendProviders(providerSelection, billingProviders),
};
}
@ -709,7 +712,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
} 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<AutomationEditorProps> = ({
{/* Allowed AI Providers */}
<div className={styles.formGroup}>
<ProviderMultiSelect
selectedProviders={allowedProviders}
onChange={setAllowedProviders}
selection={providerSelection}
onChange={setProviderSelection}
label="Erlaubte AI-Provider"
/>
<p className={styles.formHint}>
Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt.
Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.
</p>
</div>
</div>

View file

@ -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;
@ -146,7 +146,25 @@
font-size: 10px;
color: var(--color-text-secondary, #999);
flex-shrink: 0;
}
.scopeIcons {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.rightZone {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.rightZone .actions {
margin-left: 0;
}
.rootActions {

View file

@ -13,6 +13,7 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
import styles from './FolderTree.module.css';
/* ── Public types ──────────────────────────────────────────────────────── */
@ -30,6 +31,8 @@ export interface FileNode {
mimeType?: string;
fileSize?: number;
folderId?: string | null;
scope?: string;
neutralize?: boolean;
}
export interface TreeItem {
@ -62,6 +65,8 @@ export interface FolderTreeProps {
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
@ -146,6 +151,22 @@ function _fileIcon(mime?: string): string {
/* ── Selection context threaded through the tree ──────────────────────── */
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
const _SCOPE_LABELS: Record<string, string> = {
personal: 'Persönlich',
featureInstance: 'Instanz',
mandate: 'Mandant',
global: 'Global',
};
interface SelectionCtx {
selectedItemIds: Set<string>;
selectedFileIds: string[];
@ -156,6 +177,8 @@ interface SelectionCtx {
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */
@ -227,39 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
) : (
<span className={styles.folderName}>{file.fileName}</span>
)}
{!renaming && file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
{!renaming && (
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)}
{multiSelected && isSelected ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
<span className={styles.rightZone}>
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
<FaPen />
</button>
)
)}
{multiSelected && isSelected ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
<FaTrash />
</button>
)
)}
</span>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
{file.scope != null && (
<span className={styles.scopeIcons}>
<button
className={styles.actionBtn}
onClick={(e) => {
e.stopPropagation();
if (!sel.onScopeChange) return;
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
sel.onScopeChange(file.id, next);
}}
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
</button>
<button
className={styles.actionBtn}
onClick={(e) => {
e.stopPropagation();
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
}}
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
</span>
)}
</span>
)}
@ -277,6 +331,7 @@ interface TreeNodeProps {
showFiles: boolean;
filesByFolder: Map<string, FileNode[]>;
sel: SelectionCtx;
promptFolderName: (message: string, options?: PromptOptions) => Promise<string | null>;
onToggle: (id: string) => void;
onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
@ -291,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,
@ -321,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();
@ -488,6 +544,7 @@ function _TreeNode({
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={onToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
@ -517,11 +574,13 @@ export default function FolderTree({
expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle,
}: FolderTreeProps) {
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const lastClickedIdRef = useRef<string | null>(null);
const { prompt: promptFolderName, PromptDialog } = usePrompt();
const expandedIds = externalExpandedIds ?? internalExpandedIds;
@ -634,8 +693,10 @@ export default function FolderTree({
onDeleteFile,
onDeleteFiles,
onDeleteFolders,
onScopeChange,
onNeutralizeToggle,
};
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]);
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
@ -699,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"
@ -720,6 +781,7 @@ export default function FolderTree({
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={_handleToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
@ -736,6 +798,7 @@ export default function FolderTree({
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
<PromptDialog />
</div>
);
}

View file

@ -226,6 +226,7 @@ export interface FormGeneratorTableProps<T = any> {
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
groupDefaultExpanded?: boolean;
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
initialSearchTerm?: string;
rowDraggable?: boolean;
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
}
@ -327,6 +328,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
groupRowData,
groupDefaultExpanded = true,
groupActions,
initialSearchTerm = '',
rowDraggable = false,
onRowDragStart,
}: FormGeneratorTableProps<T>) {
@ -368,7 +370,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}, [providedColumns, data]);
// State management
const [searchTerm, setSearchTerm] = useState('');
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
const [searchFocused, setSearchFocused] = useState(false);
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
// Multi-column sorting: array of sort configs in order of priority

View file

@ -282,6 +282,27 @@
margin-top: 0.5rem;
}
/* Rename button (inline, hover-visible via TreeNavigation nodeActions) */
.renameButton {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: none;
border-radius: 3px;
background: transparent;
color: var(--text-tertiary, #888);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.renameButton:hover {
color: var(--primary-color, #2563eb);
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
}
/* Dark Theme */
:global(.dark-theme) .separator {
background: var(--border-dark, #333);

View file

@ -20,7 +20,7 @@
* - Users, Mandates, Roles, ...
*/
import React, { useMemo } from 'react';
import React, { useMemo, useCallback } from 'react';
import { useNavigation } from '../../hooks/useNavigation';
import type {
DynamicBlock,
@ -31,8 +31,11 @@ import type {
FeatureView
} from '../../hooks/useNavigation';
import { getPageIcon } from '../../config/pageRegistry';
import { FaSpinner } from 'react-icons/fa';
import { FaSpinner, FaPen } from 'react-icons/fa';
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
import { usePrompt } from '../../hooks/usePrompt';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './MandateNavigation.module.css';
// =============================================================================
@ -84,16 +87,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
* Shows the feature icon next to the instance name for visual distinction.
* If user is instance admin, a rename icon appears on hover.
*/
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
function featureInstanceToTreeNode(
instance: FeatureInstance,
featureUiComponent: string,
onRename?: (instanceId: string, currentLabel: string) => void,
): TreeNodeItem {
const children = instance.views.map(featureViewToTreeNode);
const renameAction = instance.isAdmin && onRename ? (
<button
className={styles.renameButton}
title="Umbenennen"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
>
<FaPen size={10} />
</button>
) : undefined;
return {
id: instance.id,
label: instance.uiLabel,
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
icon: getPageIcon(featureUiComponent),
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children,
defaultExpanded: false,
actions: renameAction,
};
}
@ -106,16 +125,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent
* Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) Views
*/
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
function navigationMandateToTreeNode(
mandate: NavigationMandate,
onRename?: (instanceId: string, currentLabel: string) => void,
): TreeNodeItem | null {
if (mandate.features.length === 0) {
return null;
}
// Flatten: collect all instances from all features directly under mandate
const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) {
for (const instance of feature.instances) {
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename));
}
}
@ -134,9 +155,12 @@ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem |
/**
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
*/
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] {
function dynamicBlockToTreeNodes(
block: DynamicBlock,
onRename?: (instanceId: string, currentLabel: string) => void,
): TreeNodeItem[] {
return block.mandates
.map(navigationMandateToTreeNode)
.map((m) => navigationMandateToTreeNode(m, onRename))
.filter((node): node is TreeNodeItem => node !== null);
}
@ -169,18 +193,24 @@ const EmptyState: React.FC = () => (
// =============================================================================
export const MandateNavigation: React.FC = () => {
// Fetch navigation from new API (blocks structure, already filtered by permissions)
const { blocks, loading } = useNavigation('de');
// Build navigation items from blocks
// Groups static items into collapsible containers:
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
// - "Administration": admin items, possibly with subgroups
// - Dynamic block (mandates) renders between them
const { blocks, loading, refresh } = useNavigation('de');
const { prompt, PromptDialog } = usePrompt();
const { showWarning } = useToast();
const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
if (!newLabel || newLabel.trim() === currentLabel) return;
try {
await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() });
refresh();
} catch (err: any) {
showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message));
}
}, [refresh, prompt, showWarning]);
const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = [];
// Collect static items by category
const meineSichtItems: NavigationItem[] = [];
let adminItems: NavigationItem[] = [];
let adminSubgroups: NavSubgroup[] = [];
@ -199,15 +229,13 @@ export const MandateNavigation: React.FC = () => {
}
}
// "Meine Sicht" - collapsible container for user-facing pages
if (meineSichtItems.length > 0) {
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
}
// Dynamic block: mandates with feature instances
for (const block of blocks) {
if (block.type === 'dynamic') {
const mandateNodes = dynamicBlockToTreeNodes(block);
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename);
if (mandateNodes.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes);
@ -215,7 +243,6 @@ export const MandateNavigation: React.FC = () => {
}
}
// "Administration" - collapsible container for admin pages (with subgroup support)
if (adminSubgroups.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
@ -236,7 +263,7 @@ export const MandateNavigation: React.FC = () => {
}
return items;
}, [blocks]);
}, [blocks, _handleRename]);
// Check if user has any navigation (static or dynamic)
const hasNavigation = blocks.length > 0;
@ -260,6 +287,7 @@ export const MandateNavigation: React.FC = () => {
) : (
<EmptyState />
)}
<PromptDialog />
</div>
);
};

View file

@ -257,6 +257,22 @@
color: white;
}
/* ============================================ */
/* NODE ACTIONS (hover-reveal inline icons) */
/* ============================================ */
.nodeActions {
display: none;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
margin-left: auto;
}
.treeNode:hover .nodeActions {
display: flex;
}
/* ============================================ */
/* DARK THEME */
/* ============================================ */

View file

@ -47,6 +47,8 @@ export interface TreeNodeItem {
level?: number;
/** Data attribute for testing/identification */
dataId?: string;
/** Inline action element rendered at the end of the row (e.g. rename icon) */
actions?: ReactNode;
}
export interface TreeSectionItem {
@ -219,6 +221,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
{node.badge}
</span>
)}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
{node.actions}
</span>
)}
</>
);

View file

@ -8,6 +8,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCurrentUser } from '../../hooks/useUsers';
import { NotificationBell } from '../NotificationBell';
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
import styles from './UserSection.module.css';
export const UserSection: React.FC = () => {
@ -16,6 +17,7 @@ export const UserSection: React.FC = () => {
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [showLegalModal, setShowLegalModal] = useState(false);
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
const handleLogout = async () => {
setIsLoggingOut(true);
@ -41,6 +43,13 @@ export const UserSection: React.FC = () => {
setShowLegalModal(true);
setShowMenu(false);
};
const handleOnboarding = () => {
_showOnboarding();
setOnboardingHidden(false);
navigate('/', { state: { showOnboarding: Date.now() } });
setShowMenu(false);
};
if (!user) {
return null;
@ -61,7 +70,7 @@ export const UserSection: React.FC = () => {
<button
className={styles.userButton}
onClick={() => setShowMenu(!showMenu)}
onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
aria-expanded={showMenu}
>
<div className={styles.avatar}>
@ -94,6 +103,16 @@ export const UserSection: React.FC = () => {
Einstellungen
</button>
{onboardingHidden && (
<button
className={styles.menuItem}
onClick={handleOnboarding}
>
<span className={styles.menuIcon}>{'\uD83E\uDDED'}</span>
Onboarding-Assistent
</button>
)}
<button
className={styles.menuItem}
onClick={handleLegal}

View file

@ -18,8 +18,11 @@ const typeIcons: Record<string, React.ReactNode> = {
mention: <FaExclamationTriangle />
};
// 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');
}

View file

@ -0,0 +1,311 @@
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;
label: string;
description: string;
completed: boolean;
action?: () => void;
}
interface OnboardingAssistantProps {
onDismiss?: () => void;
}
const _STORAGE_KEY = 'onboarding_hidden';
const _CALLOUTS: Record<string, string> = {
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.',
};
export function _isOnboardingHidden(): boolean {
try {
return localStorage.getItem(_STORAGE_KEY) === 'true';
} catch {
return false;
}
}
export function _showOnboarding(): void {
try {
localStorage.removeItem(_STORAGE_KEY);
} catch { /* ignore */ }
}
function _hideOnboarding(): void {
try {
localStorage.setItem(_STORAGE_KEY, 'true');
} catch { /* ignore */ }
}
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
const navigate = useNavigate();
const location = useLocation();
const [hidden, setHidden] = useState(() => _isOnboardingHidden());
const [steps, setSteps] = useState<OnboardingStep[]>([]);
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[] = [];
// 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 || [];
hasAdminMandate = Array.isArray(mandates) && mandates.length > 0;
} catch { /* ignore */ }
// Check if user has any feature access (via navigation = mandate member)
let hasFeature = false;
let workspaceInstancePath: string | undefined;
let workspaceInstanceIds: string[] = [];
try {
const navRes = await api.get('/api/navigation?language=de');
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 || []) {
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',
description: hasFeature
? 'Du hast aktive Features.'
: 'Aktiviere dein erstes Feature im Store.',
completed: hasFeature,
action: hasFeature ? undefined : () => navigate('/store'),
});
let hasConnection = false;
try {
const connRes = await api.get('/api/connections/');
const items = connRes.data?.items || connRes.data?.data || connRes.data || [];
hasConnection = Array.isArray(items) && items.length > 0;
} catch { /* ignore */ }
onboardingSteps.push({
id: 'connection',
label: 'Erste Datenquelle einbinden',
description: hasConnection
? 'Du hast Verbindungen eingerichtet.'
: 'Verbinde deine erste Datenquelle.',
completed: hasConnection,
action: hasConnection ? undefined : () => navigate('/basedata/connections'),
});
let hasChat = false;
for (const instId of workspaceInstanceIds) {
if (hasChat) break;
try {
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 = workspaceInstancePath ? () => navigate(workspaceInstancePath!) : undefined;
onboardingSteps.push({
id: 'chat',
label: 'Ersten AI-Chat starten',
description: hasChat
? 'Du hast bereits Chats gestartet.'
: 'Starte deinen ersten Chat mit dem AI-Assistenten.',
completed: hasChat,
action: hasChat ? undefined : chatAction,
});
setSteps(onboardingSteps);
if (onboardingSteps.every(s => s.completed)) {
setHidden(true);
_hideOnboarding();
}
} catch (err) {
console.error('Onboarding check failed:', err);
} finally {
setLoading(false);
}
}, [navigate]);
useEffect(() => {
const state = location.state as { showOnboarding?: number } | null;
if (state?.showOnboarding) {
setHidden(false);
window.history.replaceState({}, '');
}
}, [location.state]);
useEffect(() => {
if (!hidden) _checkOnboardingState();
}, [hidden, _checkOnboardingState]);
const _handleDismiss = () => {
if (dontShowAgain) {
_hideOnboarding();
}
setHidden(true);
onDismiss?.();
};
if (showWizard) {
return (
<OnboardingWizard
onComplete={() => {
setShowWizard(false);
_checkOnboardingState();
}}
onDismiss={() => setShowWizard(false)}
/>
);
}
if (hidden || loading) return null;
const completedCount = steps.filter(s => s.completed).length;
if (completedCount === steps.length) return null;
return (
<div style={{
padding: 16, margin: '0 0 20px 0', borderRadius: 12,
border: '1px solid var(--border-color, #e5e7eb)',
background: 'linear-gradient(135deg, var(--bg-primary, #fff) 0%, #eef2ff 100%)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Willkommen bei PowerOn</h3>
<p style={{ margin: '4px 0 0', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
{completedCount} von {steps.length} Schritten abgeschlossen
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 16, height: 4 }}>
{steps.map((step) => (
<div
key={step.id}
style={{
flex: 1, borderRadius: 2, height: 4,
background: step.completed ? 'var(--accent, #4f46e5)' : 'var(--border-color, #e5e7eb)',
transition: 'background 0.3s',
}}
/>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step, idx) => {
const isNextStep = !step.completed && steps.slice(0, idx).every(s => s.completed);
return (
<div key={step.id}>
<div
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
borderRadius: 8, background: step.completed ? 'transparent' : 'var(--bg-primary, #fff)',
border: step.completed ? 'none' : isNextStep ? '1px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
opacity: step.completed ? 0.6 : 1,
cursor: step.action ? 'pointer' : 'default',
}}
onClick={step.action}
>
<span style={{ fontSize: '1.1rem', flexShrink: 0, width: 24, textAlign: 'center' }}>
{step.completed ? '\u2713' : '\u25CB'}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: '0.85rem', textDecoration: step.completed ? 'line-through' : 'none' }}>
{step.label}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)' }}>
{step.description}
</div>
</div>
{step.action && !step.completed && (
<span style={{ fontSize: '0.8rem', color: 'var(--accent, #4f46e5)', fontWeight: 500 }}>{'\u2192'}</span>
)}
</div>
{isNextStep && _CALLOUTS[step.id] && (
<div style={{
marginTop: 4, marginLeft: 34, padding: '6px 10px',
fontSize: '0.78rem', color: 'var(--accent, #4f46e5)',
background: 'rgba(79, 70, 229, 0.06)', borderRadius: 6,
borderLeft: '3px solid var(--accent, #4f46e5)',
}}>
{_CALLOUTS[step.id]}
</div>
)}
</div>
);
})}
</div>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginTop: 14, paddingTop: 10,
borderTop: '1px solid var(--border-color, #e5e7eb)',
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={dontShowAgain}
onChange={(e) => setDontShowAgain(e.target.checked)}
style={{ margin: 0 }}
/>
Nicht wieder anzeigen
</label>
<button
onClick={_handleDismiss}
style={{
border: '1px solid var(--border-color, #d1d5db)',
background: 'transparent',
cursor: 'pointer',
fontSize: '0.8rem',
color: 'var(--text-secondary, #6b7280)',
padding: '4px 12px',
borderRadius: 6,
}}
>
Schliessen
</button>
</div>
</div>
);
};
export default OnboardingAssistant;

View file

@ -0,0 +1,122 @@
import React, { useState } from 'react';
import api from '../api';
interface OnboardingWizardProps {
onComplete: () => void;
onDismiss: () => void;
}
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => {
const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D');
const [mandateName, setMandateName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const _handleSubmit = async () => {
setLoading(true);
setError(null);
try {
const res = await api.post('/api/local/onboarding', {
planKey,
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');
} finally {
setLoading(false);
}
};
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999,
}}>
<div style={{
background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px',
maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Mandant erstellen</h2>
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
<label style={{
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
border: planKey === 'TRIAL_7D' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
borderRadius: '8px', cursor: 'pointer',
}}>
<input type="radio" name="plan" checked={planKey === 'TRIAL_7D'}
onChange={() => setPlanKey('TRIAL_7D')} />
<div>
<strong>Kostenlos testen</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
7 Tage gratis, danach flexibel upgraden
</div>
</div>
</label>
<label style={{
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
border: planKey === 'STANDARD_MONTHLY' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
borderRadius: '8px', cursor: 'pointer',
}}>
<input type="radio" name="plan" checked={planKey === 'STANDARD_MONTHLY'}
onChange={() => setPlanKey('STANDARD_MONTHLY')} />
<div>
<strong>Standard (Monatlich)</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Team-Workspace mit vollem Funktionsumfang
</div>
</div>
</label>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
Name des Mandanten <span style={{ fontWeight: 400, color: 'var(--text-secondary, #666)' }}>(optional)</span>
</label>
<input
type="text" value={mandateName}
onChange={(e) => setMandateName(e.target.value)}
placeholder="z. B. Firmenname oder Projektname"
style={{
width: '100%', padding: '10px 12px', borderRadius: '6px',
border: '1px solid var(--border, #d1d5db)', fontSize: '1rem',
boxSizing: 'border-box',
}}
/>
</div>
{error && <p style={{ color: '#ef4444', margin: '0 0 16px' }}>{error}</p>}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button onClick={onDismiss} style={{
padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)',
background: 'transparent', cursor: 'pointer',
}}>
Abbrechen
</button>
<button onClick={_handleSubmit} disabled={loading}
style={{
padding: '10px 20px', borderRadius: '6px', border: 'none',
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
opacity: loading ? 0.6 : 1,
}}>
{loading ? 'Wird eingerichtet...' : 'Mandant erstellen'}
</button>
</div>
</div>
</div>
);
};
export default OnboardingWizard;

View file

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

View file

@ -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<string, string> = {
anthropic: 'Anthropic (Claude)',
@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record<string, string> = {
internal: 'Internal',
};
// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖',
openai: '💬',
@ -58,20 +118,20 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
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 (
<div className={`${styles.providerSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>}
@ -93,12 +153,12 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
};
// ============================================================================
// 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<ProviderMultiSelectProps> = ({
selectedProviders,
selection,
onChange,
disabled = false,
className,
@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div
<div
ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
>
{/* Trigger Button - styled like iconButton */}
<button
<button
type="button"
className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)}
@ -220,36 +283,35 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
>
<span className={styles.buttonIcon}>{summaryIcon}</span>
</button>
{/* Dropdown Content */}
{isExpanded && (
<div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
<div className={styles.selectActions}>
<button
type="button"
onClick={handleSelectAll}
<button
type="button"
onClick={_handleSelectAll}
disabled={disabled}
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
className={`${styles.actionButton} ${allSelected ? styles.active : ''}`}
>
Alle
</button>
</div>
{loading ? (
<div className={styles.loading}>Lade...</div>
) : (
<div className={styles.checkboxList}>
{allowedProviders.map((provider) => (
<label
key={provider}
<label
key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
>
<input
type="checkbox"
checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)}
onChange={() => _handleToggle(provider)}
disabled={disabled}
/>
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
@ -260,12 +322,8 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
))}
</div>
)}
{isAllSelected && !loading && (
<div className={styles.hint}>
Alle Provider aktiv (kein Filter)
</div>
)}
<div className={styles.hint}>{summaryHint}</div>
</div>
)}
</div>
@ -288,7 +346,7 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>;
}
return (
<div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => (
@ -300,5 +358,4 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
);
};
// Default export
export default ProviderSelect;

View file

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

View file

@ -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({
</div>
<div className={styles.fileMeta}>
<span className={styles.fileSize}>
{formatFileSize(file.fileSize)}
{formatBinaryDataSizeBytes(file.fileSize)}
</span>
{file.source && (
<span className={styles.fileSource}>
@ -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;

View file

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

View file

@ -15,6 +15,12 @@ export interface MessageDocument {
taskNumber: number;
actionNumber: number;
actionId: string;
documentName?: string;
validationMetadata?: {
neutralized?: boolean;
skipped?: boolean;
[key: string]: unknown;
};
}
/**

View file

@ -0,0 +1,313 @@
.chatsTab {
display: flex;
flex-direction: column;
gap: 8px;
}
.toolbar {
display: flex;
gap: 6px;
align-items: center;
}
.search {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
font-size: 0.85rem;
background: var(--bg-input, #fff);
color: var(--text-primary, #111);
}
.createBtn {
padding: 6px 10px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
background: var(--accent, #4f46e5);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
line-height: 1;
transition: background 0.15s;
}
.createBtn:hover {
background: var(--accent-hover, #4338ca);
}
.modeToggle {
padding: 6px 8px;
border: 1px solid var(--border-color, #d1d5db);
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 0.85rem;
}
.modeActive {
background: var(--bg-active, #eef2ff);
}
/* ── Aktiv / Archiv filter tabs ── */
.filterTabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-color, #e5e7eb);
}
.filterTab {
flex: 1;
padding: 6px 0;
font-size: 0.8rem;
font-weight: 600;
text-align: center;
border: none;
background: none;
cursor: pointer;
color: var(--text-secondary, #6b7280);
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
}
.filterTab:hover {
color: var(--text-primary, #111);
}
.filterTabActive {
color: var(--accent, #4f46e5);
border-bottom-color: var(--accent, #4f46e5);
}
/* ── Loading / Empty ── */
.loading,
.emptyState {
padding: 16px;
text-align: center;
color: var(--text-secondary, #6b7280);
font-size: 0.85rem;
}
/* ── Chat list ── */
.flatList,
.tree {
display: flex;
flex-direction: column;
}
.chatItem {
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
font-size: 0.85rem;
position: relative;
gap: 6px;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.chatItem:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
}
.chatItemActive {
background: var(--primary-light, #eef2ff);
border-color: var(--accent, #4f46e5);
font-weight: 500;
}
.chatItemActive:hover {
background: var(--primary-light, #eef2ff);
}
.chatItemArchived {
opacity: 0.65;
}
.chatDate {
font-size: 0.7rem;
color: var(--text-secondary, #9ca3af);
flex-shrink: 0;
min-width: 36px;
}
.chatLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
/* ── Inline action icons (show on hover) ── */
.chatActions {
display: none;
gap: 2px;
flex-shrink: 0;
margin-left: auto;
align-items: center;
}
.chatItem:hover .chatActions {
display: flex;
}
.actionBtn {
background: none;
border: none;
cursor: pointer;
padding: 2px 3px;
border-radius: 4px;
font-size: 0.75rem;
line-height: 1;
transition: background 0.15s;
opacity: 0.7;
}
.actionBtn:hover {
background: rgba(0, 0, 0, 0.06);
opacity: 1;
}
.actionBtnDanger:hover {
background: rgba(220, 38, 38, 0.1);
}
.renameInput {
flex: 1;
min-width: 0;
font-size: 0.85rem;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--accent, #4f46e5);
outline: none;
background: var(--bg-input, #fff);
color: var(--text-primary, #111);
}
/* ── Tree sections (feature code level) ── */
.treeSection {
margin-bottom: 4px;
}
.treeSectionHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-secondary, #6b7280);
}
.treeSectionHeader:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
color: var(--text-primary, #111);
}
.treeSectionLabel {
flex: 1;
}
/* ── Tree groups (feature instance level) ── */
.treeGroup {
margin-bottom: 2px;
}
.treeGroupHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
font-size: 0.85rem;
}
.treeGroupHeader:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
}
.treeGroupCurrent {
color: var(--accent, #4f46e5);
}
.treeArrow {
font-size: 0.7rem;
width: 12px;
}
.treeGroupLabel {
flex: 1;
}
.treeGroupCount {
font-size: 0.75rem;
color: var(--text-secondary, #9ca3af);
background: var(--bg-badge, #f3f4f6);
padding: 1px 6px;
border-radius: 10px;
}
.treeChildren {
padding-left: 20px;
}
@media (prefers-color-scheme: dark) {
.search,
.renameInput {
background: var(--bg-input-dark, #1f2937);
border-color: var(--border-dark, #374151);
color: #f3f4f6;
}
.chatItem:hover,
.treeGroupHeader:hover,
.treeSectionHeader:hover {
background: rgba(255, 255, 255, 0.05);
}
.treeSectionHeader {
color: #9ca3af;
}
.treeSectionHeader:hover {
color: #f3f4f6;
}
.chatItemActive,
.chatItemActive:hover {
background: rgba(79, 70, 229, 0.15);
border-color: var(--accent, #4f46e5);
}
.treeGroupCount {
background: #374151;
color: #9ca3af;
}
.actionBtn:hover {
background: rgba(255, 255, 255, 0.08);
}
.actionBtnDanger:hover {
background: rgba(220, 38, 38, 0.15);
}
.createBtn {
border-color: var(--border-dark, #374151);
}
.filterTabs {
border-bottom-color: var(--border-dark, #374151);
}
.filterTab:hover {
color: #f3f4f6;
}
}

View file

@ -0,0 +1,444 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import styles from './ChatsTab.module.css';
interface ChatItem {
id: string;
label: string;
updatedAt?: string | number;
lastMessageAt?: string | number;
featureInstanceId?: string;
featureCode?: string;
status?: string;
}
interface ChatGroup {
featureInstanceId: string;
featureLabel: string;
featureCode: string;
chats: ChatItem[];
}
type ChatFilter = 'active' | 'archived';
interface ChatsTabProps {
context: UdbContext;
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
onDragStart?: (chatId: string, event: React.DragEvent) => void;
activeWorkflowId?: string;
onCreateNew?: () => void;
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
onDeleteChat?: (chatId: string) => void | Promise<void>;
}
function _formatRelativeTime(dateStr?: string | number): string {
if (!dateStr) return '';
const d = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
if (isNaN(d.getTime())) return '';
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffH = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `${diffMin}m`;
if (diffH < 24) return `${diffH}h`;
if (diffDays === 1) return 'gestern';
if (diffDays < 7) return `vor ${diffDays}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
const ChatsTab: React.FC<ChatsTabProps> = ({
context,
onSelectChat,
onDragStart,
activeWorkflowId,
onCreateNew,
onRenameChat,
onDeleteChat,
}) => {
const [groups, setGroups] = useState<ChatGroup[]>([]);
const [flatMode, setFlatMode] = useState(false);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ChatFilter>('active');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const _loadChats = useCallback(async (serverSearch?: string) => {
setLoading(true);
try {
const params: Record<string, unknown> = { includeArchived: true };
if (serverSearch) params.search = serverSearch;
const response = await api.get(
`/api/workspace/${context.instanceId}/workflows`,
{ params },
);
const body = response.data ?? {};
const nested = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
? (body.data as { workflows?: unknown })
: null;
const workflowsRaw =
body.workflows ??
nested?.workflows ??
(Array.isArray(body.data) ? body.data : null);
const workflows = Array.isArray(workflowsRaw) ? workflowsRaw : [];
const groupMap = new Map<string, ChatGroup>();
for (const wf of workflows) {
const fiId = wf.featureInstanceId || context.instanceId;
if (!groupMap.has(fiId)) {
groupMap.set(fiId, {
featureInstanceId: fiId,
featureLabel: wf.featureLabel || wf.featureCode || fiId.slice(0, 8),
featureCode: wf.featureCode || 'workspace',
chats: [],
});
}
groupMap.get(fiId)!.chats.push({
id: wf.id,
label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`,
updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt,
lastMessageAt: wf.lastMessageAt,
featureInstanceId: fiId,
featureCode: wf.featureCode,
status: wf.status || 'active',
});
}
const sorted = Array.from(groupMap.values());
sorted.forEach(g =>
g.chats.sort((a, b) => {
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
return tb - ta;
}),
);
setGroups(sorted);
if (expandedGroups.size === 0 && sorted.length > 0) {
const currentGroup = sorted.find(g => g.featureInstanceId === context.instanceId);
const sectionKey = currentGroup ? `section:${currentGroup.featureCode || 'workspace'}` : 'section:workspace';
setExpandedGroups(new Set([context.instanceId, sectionKey]));
}
} catch (err) {
console.error('Failed to load chats:', err);
} finally {
setLoading(false);
}
}, [context.instanceId]);
useEffect(() => { _loadChats(); }, [_loadChats]);
useEffect(() => {
const timer = setTimeout(() => {
if (search.trim().length >= 2) {
_loadChats(search.trim());
} else if (search.trim().length === 0) {
_loadChats();
}
}, 300);
return () => clearTimeout(timer);
}, [search, _loadChats]);
useEffect(() => {
if (activeWorkflowId) {
_loadChats();
}
}, [activeWorkflowId]);
useEffect(() => {
if (editingId && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [editingId]);
const _toggleGroup = (id: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const _startEditing = (chat: ChatItem) => {
if (!onRenameChat) return;
setEditingId(chat.id);
setEditName(chat.label);
};
const _commitRename = async (chatId: string) => {
const trimmed = editName.trim();
setEditingId(null);
if (!trimmed || !onRenameChat) return;
await onRenameChat(chatId, trimmed);
_loadChats();
};
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitRename(chatId);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const _archiveChat = useCallback(async (chatId: string) => {
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
_loadChats();
} catch (err) {
console.error('Failed to archive chat:', err);
}
}, [context.instanceId, _loadChats]);
const _restoreChat = useCallback(async (chatId: string) => {
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
_loadChats();
} catch (err) {
console.error('Failed to restore chat:', err);
}
}, [context.instanceId, _loadChats]);
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
const _applyFilter = (chats: ChatItem[]) =>
chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c));
const _filteredGroups = groups
.map(g => ({ ...g, chats: _applyFilter(g.chats) }))
.filter(g => g.chats.length > 0);
const _toTs = (v?: string | number): number =>
typeof v === 'number' ? v : new Date(v || 0).getTime();
const _allChats = _filteredGroups
.flatMap(g => g.chats)
.sort((a, b) => {
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
return tb - ta;
});
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0);
const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
const _renderChatItem = (chat: ChatItem, featureInstanceId: string) => {
const isActive = activeWorkflowId === chat.id;
const isEditing = editingId === chat.id;
const archived = _isArchived(chat);
const itemClassName = [
styles.chatItem,
isActive ? styles.chatItemActive : '',
archived ? styles.chatItemArchived : '',
].filter(Boolean).join(' ');
return (
<div
key={chat.id}
className={itemClassName}
onClick={() => {
if (!isEditing) onSelectChat?.(chat.id, featureInstanceId);
}}
draggable={!!onDragStart && !isEditing}
onDragStart={(e) => {
e.dataTransfer.setData('application/chat-id', chat.id);
e.dataTransfer.setData('text/plain', chat.label);
onDragStart?.(chat.id, e);
}}
>
{isEditing ? (
<input
ref={renameInputRef}
className={styles.renameInput}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={() => _commitRename(chat.id)}
onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<span className={styles.chatDate}>
{_formatRelativeTime(chat.updatedAt)}
</span>
<span
className={styles.chatLabel}
title={chat.label}
>
{chat.label}
</span>
<span className={styles.chatActions}>
{onRenameChat && (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _startEditing(chat); }}
title="Umbenennen"
>
</button>
)}
{archived ? (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _restoreChat(chat.id); }}
title="Wiederherstellen"
>
</button>
) : (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _archiveChat(chat.id); }}
title="Archivieren"
>
📦
</button>
)}
{onDeleteChat && (
<button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
title="Löschen"
>
🗑
</button>
)}
</span>
</>
)}
</div>
);
};
const _featureCodeLabel = (code: string): string => {
const labels: Record<string, string> = {
workspace: 'AI Workspace',
commcoach: 'CommCoach',
trustee: 'Trustee',
automation: 'Automation',
};
return labels[code] || code;
};
if (loading) return <div className={styles.loading}>Lade Chats...</div>;
return (
<div className={styles.chatsTab}>
<div className={styles.toolbar}>
<input
className={styles.search}
type="text"
placeholder="Suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title="Neuer Chat">
+
</button>
)}
<button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)}
title={flatMode ? 'Baumansicht' : 'Listenansicht'}
>
{flatMode ? '\uD83C\uDF33' : '\uD83D\uDCCB'}
</button>
</div>
<div className={styles.filterTabs}>
<button
className={`${styles.filterTab} ${filter === 'active' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('active')}
>
Aktiv ({_activeCount})
</button>
<button
className={`${styles.filterTab} ${filter === 'archived' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('archived')}
>
Archiv ({_archivedCount})
</button>
</div>
{flatMode ? (
<div className={styles.flatList}>
{_allChats.map((chat) =>
_renderChatItem(chat, chat.featureInstanceId || context.instanceId),
)}
</div>
) : (
<div className={styles.tree}>
{(() => {
const byFeatureCode = new Map<string, ChatGroup[]>();
for (const g of _filteredGroups) {
const code = g.featureCode || 'workspace';
if (!byFeatureCode.has(code)) byFeatureCode.set(code, []);
byFeatureCode.get(code)!.push(g);
}
return Array.from(byFeatureCode.entries()).map(([code, instances]) => (
<div key={code} className={styles.treeSection}>
<div
className={styles.treeSectionHeader}
onClick={() => _toggleGroup(`section:${code}`)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(`section:${code}`) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeSectionLabel}>
{_featureCodeLabel(code)}
</span>
<span className={styles.treeGroupCount}>
{instances.reduce((n, g) => n + g.chats.length, 0)}
</span>
</div>
{expandedGroups.has(`section:${code}`) && instances.map((group) => (
<div key={group.featureInstanceId} className={styles.treeGroup}>
{instances.length > 1 && (
<div
className={`${styles.treeGroupHeader} ${
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
}`}
onClick={() => _toggleGroup(group.featureInstanceId)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
<span className={styles.treeGroupCount}>{group.chats.length}</span>
</div>
)}
{(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && (
<div className={styles.treeChildren}>
{group.chats.map((chat) =>
_renderChatItem(chat, group.featureInstanceId),
)}
</div>
)}
</div>
))}
</div>
));
})()}
</div>
)}
{_allChats.length === 0 && (
<div className={styles.emptyState}>
{filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'}
</div>
)}
</div>
);
};
export default ChatsTab;

View file

@ -0,0 +1,95 @@
.filesTab {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
}
.loading,
.empty {
padding: 16px;
text-align: center;
color: var(--text-secondary, #6b7280);
font-size: 0.85rem;
}
.fileList {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.fileRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.fileRow:hover {
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
}
.fileName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fileIcons {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.scopeIcon,
.neutralizeIcon {
border: none;
background: transparent;
cursor: pointer;
font-size: 0.9rem;
padding: 2px 4px;
border-radius: 4px;
opacity: 0.6;
transition: opacity 0.15s;
}
.scopeIcon:hover,
.neutralizeIcon:hover {
opacity: 1;
background: var(--bg-hover, rgba(0, 0, 0, 0.06));
}
.neutralizeActive {
opacity: 1;
}
.legend {
display: flex;
gap: 12px;
padding: 8px 10px;
border-top: 1px solid var(--border-color, #e5e7eb);
font-size: 0.75rem;
color: var(--text-secondary, #9ca3af);
flex-shrink: 0;
flex-wrap: wrap;
}
@media (prefers-color-scheme: dark) {
.fileRow:hover {
background: rgba(255, 255, 255, 0.05);
}
.scopeIcon:hover,
.neutralizeIcon:hover {
background: rgba(255, 255, 255, 0.08);
}
.legend {
border-top-color: var(--border-dark, #374151);
}
}

View file

@ -0,0 +1,337 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import FolderTree from '../../components/FolderTree/FolderTree';
import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useFileContext } from '../../contexts/FileContext';
import styles from './FilesTab.module.css';
interface FileEntry {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
tags?: string[];
scope: string;
neutralize: boolean;
}
interface FilesTabProps {
context: UdbContext;
onFileSelect?: (fileId: string) => void;
}
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
const [files, setFiles] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
folders,
refreshFolders,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
handleDownloadFolder,
expandedFolderIds,
toggleFolderExpanded,
} = useFileContext();
const _loadFiles = useCallback(async () => {
setLoading(true);
try {
const response = await api.get(`/api/workspace/${context.instanceId}/files`);
const body = response.data;
const rawList =
(Array.isArray(body?.files) && body.files) ||
(Array.isArray(body?.data) && body.data) ||
(Array.isArray(body) ? body : []);
setFiles(
rawList.map((f: any) => ({
id: f.id,
fileName: f.fileName || f.name || 'unknown',
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
tags: f.tags || [],
scope: f.scope || 'personal',
neutralize: f.neutralize || false,
})),
);
} catch (err) {
console.error('Failed to load files:', err);
} finally {
setLoading(false);
}
}, [context.instanceId]);
useEffect(() => {
_loadFiles();
}, [_loadFiles]);
useEffect(() => {
const _onFileUploaded = () => {
setTimeout(() => _loadFiles(), 150);
};
window.addEventListener('fileUploaded', _onFileUploaded as EventListener);
return () => window.removeEventListener('fileUploaded', _onFileUploaded as EventListener);
}, [_loadFiles]);
const _folderNodes = useMemo(() =>
folders.map(f => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
})),
[folders],
);
const _fileNodes: FileNode[] = useMemo(() => {
let result = files;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(f =>
f.fileName.toLowerCase().includes(q)
|| (f.tags || []).some((t: string) => t.toLowerCase().includes(q)),
);
}
return result
.sort((a, b) => a.fileName.localeCompare(b.fileName))
.map(f => ({
id: f.id,
fileName: f.fileName,
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
scope: f.scope,
neutralize: f.neutralize,
}));
}, [files, searchQuery]);
const _refreshAll = useCallback(() => {
_loadFiles();
refreshFolders();
}, [_loadFiles, refreshFolders]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return;
setUploading(true);
try {
for (const file of Array.from(fileList)) {
const formData = new FormData();
formData.append('file', file);
formData.append('featureInstanceId', context.instanceId);
await api.post('/api/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
_refreshAll();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
}, [context.instanceId, uploading, _refreshAll]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}
}, []);
const _handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const _handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (e.dataTransfer.files.length > 0) {
_uploadFiles(e.dataTransfer.files);
}
}, [_uploadFiles]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
_uploadFiles(e.target.files);
e.target.value = '';
}
}, [_uploadFiles]);
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
_loadFiles();
}, [handleMoveFile, _loadFiles]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
_loadFiles();
}, [contextMoveFiles, _loadFiles]);
const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
_loadFiles();
}, [handleDeleteFolder, selectedFolderId, _loadFiles]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName });
_loadFiles();
}, [_loadFiles]);
const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
_loadFiles();
}, [handleFileDelete, _loadFiles]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds });
_loadFiles();
}, [_loadFiles]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
refreshFolders();
_loadFiles();
}, [refreshFolders, _loadFiles]);
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, scope: newScope } : f)));
try {
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
} catch (err) {
console.error('Failed to update scope:', err);
_loadFiles();
}
}, [_loadFiles]);
const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, neutralize: newValue } : f)));
try {
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
} catch (err) {
console.error('Failed to toggle neutralize:', err);
_loadFiles();
}
}, [_loadFiles]);
if (loading) return <div className={styles.loading}>Lade Dateien...</div>;
return (
<div
className={styles.filesTab}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #F25843', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#F25843',
}}>
Dateien hier ablegen
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px' }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title="Upload files"
>
{uploading ? '...' : '+'}
</button>
<button
onClick={_refreshAll}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
>
{'\u21BB'}
</button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={_handleFileInputChange}
/>
<input
type="text"
placeholder="Dateien suchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px',
}}
/>
<div style={{ flex: 1, overflow: 'auto' }}>
<FolderTree
folders={_folderNodes}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle}
/>
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
</div>
)}
</div>
<div className={styles.legend}>
<span>{'\uD83D\uDC64'} Persönlich</span>
<span>{'\uD83D\uDC65'} Instanz</span>
<span>{'\uD83C\uDFE2'} Mandant</span>
<span>{'\uD83D\uDD12'} Neutralisiert</span>
</div>
</div>
);
};
export default FilesTab;

View file

@ -0,0 +1,11 @@
.sourcesTab {
height: 100%;
overflow-y: auto;
}
.placeholder {
padding: 16px;
text-align: center;
color: var(--text-secondary, #6b7280);
font-size: 0.85rem;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
.udb {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.tabBar {
display: flex;
gap: 2px;
padding: 8px 8px 0;
border-bottom: 1px solid var(--border-color, #e5e7eb);
flex-shrink: 0;
}
.tab {
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--text-secondary, #6b7280);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
}
.tab:hover {
color: var(--text-primary, #111827);
background: var(--bg-hover, rgba(0, 0, 0, 0.03));
}
.tabActive {
color: var(--accent, #4f46e5);
border-bottom-color: var(--accent, #4f46e5);
}
.tabContent {
flex: 1;
overflow-y: auto;
padding: 8px;
}
@media (prefers-color-scheme: dark) {
.tabBar {
border-bottom-color: var(--border-color-dark, #374151);
}
.tab {
color: var(--text-secondary-dark, #9ca3af);
}
.tab:hover {
color: var(--text-primary-dark, #f3f4f6);
background: rgba(255, 255, 255, 0.05);
}
.tabActive {
color: var(--accent, #818cf8);
border-bottom-color: var(--accent, #818cf8);
}
}

View file

@ -0,0 +1,103 @@
import React, { useState } from 'react';
import ChatsTab from './ChatsTab';
import FilesTab from './FilesTab';
import SourcesTab from './SourcesTab';
import styles from './UnifiedDataBar.module.css';
export type UdbTab = 'chats' | 'files' | 'sources';
export interface UdbContext {
instanceId: string;
mandateId?: string;
featureInstanceId?: string;
userId?: string;
}
interface UnifiedDataBarProps {
context: UdbContext;
activeTab?: UdbTab;
onTabChange?: (tab: UdbTab) => void;
hideTabs?: UdbTab[];
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
activeWorkflowId?: string;
onCreateNewChat?: () => void;
onRenameChat?: (chatId: string, newName: string) => void;
onDeleteChat?: (chatId: string) => void;
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
onFileSelect?: (fileId: string) => void;
onSourcesChanged?: () => void;
className?: string;
}
const _TAB_LABELS: Record<UdbTab, Record<string, string>> = {
chats: { de: 'Chats', en: 'Chats', fr: 'Chats' },
files: { de: 'Dateien', en: 'Files', fr: 'Fichiers' },
sources: { de: 'Quellen', en: 'Sources', fr: 'Sources' },
};
const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
context,
activeTab: controlledTab,
onTabChange,
hideTabs,
onSelectChat,
activeWorkflowId,
onCreateNewChat,
onRenameChat,
onDeleteChat,
onChatDragStart,
onFileSelect,
onSourcesChanged,
className,
}) => {
const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter(
t => !hideTabs?.includes(t),
);
const [internalTab, setInternalTab] = useState<UdbTab>(controlledTab ?? visibleTabs[0] ?? 'chats');
const currentTab = controlledTab ?? internalTab;
const _handleTabChange = (tab: UdbTab) => {
setInternalTab(tab);
onTabChange?.(tab);
};
return (
<div className={`${styles.udb} ${className || ''}`}>
<div className={styles.tabBar}>
{visibleTabs.map((tab) => (
<button
key={tab}
className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`}
onClick={() => _handleTabChange(tab)}
>
{_TAB_LABELS[tab].de}
</button>
))}
</div>
<div className={styles.tabContent}>
{currentTab === 'chats' && !hideTabs?.includes('chats') && (
<ChatsTab
context={context}
onSelectChat={onSelectChat}
onDragStart={onChatDragStart}
activeWorkflowId={activeWorkflowId}
onCreateNew={onCreateNewChat}
onRenameChat={onRenameChat}
onDeleteChat={onDeleteChat}
/>
)}
{currentTab === 'files' && !hideTabs?.includes('files') && (
<FilesTab
context={context}
onFileSelect={onFileSelect}
/>
)}
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
<SourcesTab context={context} onSourcesChanged={onSourcesChanged} />
)}
</div>
</div>
);
};
export default UnifiedDataBar;

View file

@ -0,0 +1,3 @@
export { default as UnifiedDataBar } from './UnifiedDataBar';
export type { UdbContext, UdbTab } from './UnifiedDataBar';
export { useUdlContext } from './useUdlContext';

View file

@ -0,0 +1,23 @@
import { useMemo } from 'react';
import type { UdbContext } from './UnifiedDataBar';
/**
* Build a UDL (Unified Data Layer) context from the current feature instance.
* Features use this to query scope-based data from the UDL
* instead of instance-scoped data silos.
*
* FeatureInstance -> UI-Scope (workflow surface)
* UDL -> Data-Scope (actual data access boundary)
*/
export function useUdlContext(
instanceId: string,
mandateId?: string,
userId?: string
): UdbContext {
return useMemo(() => ({
instanceId,
mandateId,
featureInstanceId: instanceId,
userId,
}), [instanceId, mandateId, userId]);
}

View file

@ -165,7 +165,7 @@ export function useMandates() {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
@ -305,7 +305,7 @@ export function useMandates() {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -206,7 +206,7 @@ export function useRbacRoles() {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
@ -346,7 +346,7 @@ export function useRbacRoles() {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -182,7 +182,7 @@ export function useRbacRules() {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
@ -322,7 +322,7 @@ export function useRbacRules() {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -279,6 +279,7 @@ export function useRegister() {
interface GoogleAuthResponse {
accessToken: string;
tokenType: string;
isNewUser?: boolean;
user: {
username: string;
email: string;

View file

@ -536,7 +536,7 @@ export function useAutomationTemplates() {
return await fetchAutomationTemplateById(request, templateId);
}, [request]);
const createTemplate = useCallback(async (data: Omit<AutomationTemplate, 'id' | '_createdAt' | '_createdBy'>) => {
const createTemplate = useCallback(async (data: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>) => {
return await createAutomationTemplateApi(request, data);
}, [request]);

View file

@ -43,7 +43,7 @@ export type {
MandateUserSummary,
};
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
export type { TransactionType, ReferenceType } from '../api/billingApi';
/**
* Hook for user billing operations
@ -217,34 +217,21 @@ export function useBillingAdmin(mandateId?: string) {
}
}, [request, mandateId]);
// Update settings — after billing model change, reload dependent data (accounts / users / tx)
const saveSettings = useCallback(
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
const mId = targetMandateId || mandateId;
if (!mId) return null;
const previousModel = settings?.billingModel;
try {
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
setSettings(data);
const newModel = settingsUpdate.billingModel;
const modelChanged =
newModel !== undefined && newModel !== null && newModel !== previousModel;
if (modelChanged) {
await Promise.all([
loadAccounts(mId),
loadTransactions(mId, 100),
loadUsers(mId),
]);
}
return data;
} catch (err) {
console.error('Error saving billing settings:', err);
throw err;
}
},
[request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers]
[request, mandateId]
);
// Add credit (manual, admin)

View file

@ -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<string, unknown>; result?: string; success?: boolean }>;
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
archiveContext: (contextId: string) => Promise<void>;
startSession: (personaId?: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>;
sendMessage: (content: string, options?: SendMessageOptions) => Promise<void>;
sendAudio: (audioBlob: Blob) => Promise<void>;
completeSession: () => Promise<void>;
cancelSession: () => Promise<void>;
@ -67,9 +70,10 @@ export interface CommcoachHookReturn {
refreshContexts: () => Promise<void>;
}
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<CoachingContext[]>([]);
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn {
const [error, setError] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [agentToolCalls, setAgentToolCalls] = useState<Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>>([]);
const [actionLoading, setActionLoading] = useState<string | null>(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,

View file

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

View file

@ -83,11 +83,11 @@ export function useTablePermission(tableName: string) {
canDelete: hasAccess(permission.delete),
// Record-basierte Prüfungen
canReadRecord: (record: { _createdBy?: string }) =>
canReadRecord: (record: { sysCreatedBy?: string }) =>
canAccessRecord(permission.read, record, userId),
canUpdateRecord: (record: { _createdBy?: string }) =>
canUpdateRecord: (record: { sysCreatedBy?: string }) =>
canAccessRecord(permission.update, record, userId),
canDeleteRecord: (record: { _createdBy?: string }) =>
canDeleteRecord: (record: { sysCreatedBy?: string }) =>
canAccessRecord(permission.delete, record, userId),
};
}
@ -296,7 +296,7 @@ export function useInstancePermissions(): InstancePermissions | undefined {
*/
export function useCanEditRecord(
tableName: string,
record: { _createdBy?: string } | undefined,
record: { sysCreatedBy?: string } | undefined,
userId: string
): boolean {
const { update } = useTablePermission(tableName);
@ -311,7 +311,7 @@ export function useCanEditRecord(
*/
export function useCanDeleteRecord(
tableName: string,
record: { _createdBy?: string } | undefined,
record: { sysCreatedBy?: string } | undefined,
userId: string
): boolean {
const { delete: deleteLevel } = useTablePermission(tableName);
@ -329,7 +329,7 @@ interface PermissionGateProps {
table?: string;
view?: string;
action?: 'view' | 'read' | 'create' | 'update' | 'delete';
record?: { _createdBy?: string };
record?: { sysCreatedBy?: string };
children: React.ReactNode;
fallback?: React.ReactNode;
}

View file

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

View file

@ -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<boolean> => {
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,
};

View file

@ -66,6 +66,7 @@ export interface FeatureInstance {
uiLabel: string;
order: number;
views: FeatureView[];
isAdmin?: boolean;
}
/** Feature within a mandate */

View file

@ -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<string, unknown>): 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<string, unknown>) : {})
);
}
// 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'));

161
src/hooks/usePrompt.tsx Normal file
View file

@ -0,0 +1,161 @@
/**
* usePrompt application-level prompt dialog replacing native browser prompt().
*
* Usage:
* const { prompt, PromptDialog } = usePrompt();
* const value = await prompt('Bitte Namen eingeben:', { title: 'Umbenennen' });
* if (value !== null) { ... }
* // Render <PromptDialog /> once in the component tree.
*/
import React, { useState, useCallback, useRef } from 'react';
export interface PromptOptions {
title?: string;
confirmLabel?: string;
cancelLabel?: string;
placeholder?: string;
defaultValue?: string;
variant?: 'primary' | 'danger';
}
interface PromptState {
message: string;
options: Required<PromptOptions>;
resolve: (value: string | null) => void;
}
const _defaults: Required<PromptOptions> = {
title: 'Eingabe',
confirmLabel: 'OK',
cancelLabel: 'Abbrechen',
placeholder: '',
defaultValue: '',
variant: 'primary',
};
export function usePrompt() {
const [state, setState] = useState<PromptState | null>(null);
const resolveRef = useRef<((v: string | null) => void) | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prompt = useCallback((message: string, options?: PromptOptions): Promise<string | null> => {
return new Promise<string | null>((resolve) => {
resolveRef.current = resolve;
setState({
message,
options: { ..._defaults, ...options },
resolve,
});
});
}, []);
const _handleConfirm = useCallback(() => {
const val = inputRef.current?.value ?? '';
resolveRef.current?.(val);
resolveRef.current = null;
setState(null);
}, []);
const _handleCancel = useCallback(() => {
resolveRef.current?.(null);
resolveRef.current = null;
setState(null);
}, []);
const PromptDialog: React.FC = useCallback(() => {
if (!state) return null;
const { message, options } = state;
const isDanger = options.variant === 'danger';
return (
<div
onClick={_handleCancel}
style={{
position: 'fixed', inset: 0, zIndex: 10000,
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface-color, #1a1a2e)',
border: '1px solid var(--border-color, var(--color-border, #333))',
borderRadius: '12px',
padding: '1.5rem',
minWidth: 360, maxWidth: 500,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
display: 'flex', flexDirection: 'column', gap: '1.25rem',
}}
>
<h3 style={{
margin: 0, fontSize: '1.05rem', fontWeight: 600,
color: 'var(--text-primary, #e0e0e0)',
}}>
{options.title}
</h3>
<p style={{
margin: 0, fontSize: '0.9rem', lineHeight: 1.5,
color: 'var(--text-secondary, #999)',
}}>
{message}
</p>
<input
ref={inputRef}
autoFocus
defaultValue={options.defaultValue}
placeholder={options.placeholder}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleConfirm();
if (e.key === 'Escape') _handleCancel();
}}
style={{
padding: '10px 14px',
borderRadius: '8px',
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%',
boxSizing: 'border-box',
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
<button
onClick={_handleCancel}
style={{
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
border: '1px solid var(--color-border, #444)',
background: 'transparent',
color: 'var(--text-secondary, #aaa)',
cursor: 'pointer',
}}
>
{options.cancelLabel}
</button>
<button
onClick={_handleConfirm}
style={{
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
border: 'none',
background: isDanger ? '#ef4444' : 'var(--primary-color, #F25843)',
color: '#fff',
cursor: 'pointer',
}}
>
{options.confirmLabel}
</button>
</div>
</div>
</div>
);
}, [state, _handleConfirm, _handleCancel]);
return { prompt, PromptDialog };
}

View file

@ -157,7 +157,7 @@ export function usePrompts() {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
@ -367,7 +367,7 @@ export function usePrompts() {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
@ -530,7 +530,7 @@ export function usePromptOperations() {
try {
// Pass all provided fields (supports partial inline updates like isSystem toggle)
const { id, mandateId, _createdBy, _createdAt, _modifiedAt, _permissions, ...requestBody } = updateData;
const { id, mandateId, sysCreatedBy, sysCreatedAt, sysModifiedAt, _permissions, ...requestBody } = updateData;
const updatedPrompt = await updatePromptApi(request, promptId, requestBody as UpdatePromptData);

View file

@ -165,7 +165,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
.filter(attr => {
if (attr.readonly === true || attr.editable === false) return false;
if (attr.name === 'id') return false;
const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
const nonEditable = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
return !nonEditable.includes(attr.name);
})
.map(attr => {
@ -210,7 +210,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
const generateCreateFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) return [];
return attributes
.filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
.filter(attr => !['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
.map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined;

View file

@ -11,40 +11,64 @@ import {
fetchStoreFeatures,
activateStoreFeature,
deactivateStoreFeature,
fetchUserMandates,
fetchSubscriptionInfo,
type StoreFeature,
type UserMandate,
type SubscriptionInfo,
} from '../api/storeApi';
import { useFeatureStore } from '../stores/featureStore';
interface UseStoreReturn {
features: StoreFeature[];
mandates: UserMandate[];
subscriptionInfo: SubscriptionInfo | null;
loading: boolean;
actionLoading: string | null;
error: string | null;
loadStore: () => Promise<void>;
activate: (featureCode: string) => Promise<void>;
deactivate: (featureCode: string) => Promise<void>;
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
activate: (featureCode: string, mandateId?: string) => Promise<void>;
deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise<void>;
}
export function useStore(): UseStoreReturn {
const [features, setFeatures] = useState<StoreFeature[]>([]);
const [mandates, setMandates] = useState<UserMandate[]>([]);
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const featureStore = useFeatureStore();
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
try {
const info = await fetchSubscriptionInfo(mandateId);
setSubscriptionInfo(info);
} catch {
// non-critical
}
}, []);
const loadStore = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchStoreFeatures();
const [data, userMandates] = await Promise.all([
fetchStoreFeatures(),
fetchUserMandates(),
]);
setFeatures(data);
setMandates(userMandates);
const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined;
await loadSubscriptionInfo(firstMandateId);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load store';
setError(msg);
} finally {
setLoading(false);
}
}, []);
}, [loadSubscriptionInfo]);
useEffect(() => {
loadStore();
@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn {
await loadStore();
}, [featureStore, loadStore]);
const activate = useCallback(async (featureCode: string) => {
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
setActionLoading(featureCode);
setError(null);
try {
await activateStoreFeature(featureCode);
await activateStoreFeature(featureCode, mandateId);
await _refreshAfterAction();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Activation failed';
@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn {
}
}, [_refreshAfterAction]);
const deactivate = useCallback(async (featureCode: string) => {
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
setActionLoading(featureCode);
setError(null);
try {
await deactivateStoreFeature(featureCode);
await deactivateStoreFeature(featureCode, mandateId, instanceId);
await _refreshAfterAction();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Deactivation failed';
@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn {
}
}, [_refreshAfterAction]);
return { features, loading, actionLoading, error, loadStore, activate, deactivate };
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
}
export default useStore;

View file

@ -42,10 +42,11 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
const [scheduled, setScheduled] = useState<MandateSubscription | null>(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<string | null>(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 {

View file

@ -218,7 +218,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
if (attr.name === 'id') {
return false;
}
const nonEditableFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
const nonEditableFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
@ -284,7 +284,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
return attributes
.filter(attr => {
const systemFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId'];
const systemFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId'];
return !systemFields.includes(attr.name);
})
.map(attr => {

View file

@ -175,7 +175,7 @@ export function useTrusteeAccess() {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -175,7 +175,7 @@ export function useTrusteeContracts() {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -176,7 +176,7 @@ export function useTrusteeDocuments() {
return false;
}
// documentData is handled separately (binary upload)
const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
const nonEditableFields = ['id', 'documentData', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -174,7 +174,7 @@ export function useTrusteeOrganisations() {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -163,7 +163,7 @@ export function useTrusteePositionDocuments() {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -178,7 +178,7 @@ export function useTrusteePositions() {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -176,7 +176,7 @@ export function useTrusteeRoles() {
if (attr.readonly === true || attr.editable === false) {
return false;
}
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -412,7 +412,7 @@ export function useOrgUsers() {
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {
@ -560,7 +560,7 @@ export function useOrgUsers() {
return false;
}
// Filter out ID fields and other auto-generated fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete', 'authenticationAuthority'];
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete', 'authenticationAuthority'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

@ -224,7 +224,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
return false; // Don't show readonly fields in edit form
}
// Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name);
})
.map(attr => {

View file

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

View file

@ -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 = () => {
/>
</div>
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
<div
className={styles.outletShell}
style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : undefined }}
style={{ display: hideOutletShell ? 'none' : undefined }}
>
<Outlet />
</div>

View file

@ -7,11 +7,12 @@
*/
import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import useNavigation from '../hooks/useNavigation';
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
import { getPageIcon } from '../config/pageRegistry';
import { FaArrowRight, FaBuilding } from 'react-icons/fa';
import OnboardingAssistant from '../components/OnboardingAssistant';
import styles from './Dashboard.module.css';
// =============================================================================
@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => {
);
}
if (totalInstances === 0) {
return <Navigate to="/store" replace />;
}
return (
<div className={styles.dashboard}>
<header className={styles.header}>
<h1>Übersicht</h1>
<p className={styles.subtitle}>
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
</p>
{totalInstances > 0 && (
<p className={styles.subtitle}>
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
</p>
)}
</header>
<OnboardingAssistant />
<main className={styles.content}>
{mandates
.filter(mandate => mandate.features.some(f => f.instances.length > 0))

View file

@ -219,6 +219,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ 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) {

View file

@ -242,6 +242,50 @@
text-decoration: underline;
}
.ctaSection {
display: flex;
gap: 0.75rem;
width: 100%;
}
.ctaPrimary {
flex: 1;
height: 46px;
padding: 10px 16px;
border-radius: 25px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border: none;
background-color: var(--color-secondary);
color: var(--color-text);
transition: all 0.2s ease;
font-family: var(--font-family);
}
.ctaPrimary:hover {
background-color: var(--color-secondary-hover);
}
.ctaSecondary {
flex: 1;
height: 46px;
padding: 10px 16px;
border-radius: 25px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--color-secondary);
background-color: transparent;
color: var(--color-secondary);
transition: all 0.2s ease;
font-family: var(--font-family);
}
.ctaSecondary:hover {
background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent);
}
button:disabled {
opacity: 0.7;
cursor: not-allowed;

View file

@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa';
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage';
import OnboardingWizard from '../components/OnboardingWizard';
import styles from './Login.module.css';
@ -21,13 +22,14 @@ function Login() {
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
// Check for pending invitation
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(() => {
@ -84,6 +86,10 @@ function Login() {
console.log("Attempting Google login...");
const response = await loginWithGoogle();
console.log("Google login successful:", response);
if (response?.isNewUser) {
setShowOnboardingWizard(true);
return;
}
handleSuccessfulLogin();
} catch (error) {
console.error("Google login failed:", error);
@ -104,6 +110,21 @@ function Login() {
}
};
if (showOnboardingWizard) {
return (
<OnboardingWizard
onComplete={() => {
setShowOnboardingWizard(false);
handleSuccessfulLogin();
}}
onDismiss={() => {
setShowOnboardingWizard(false);
handleSuccessfulLogin();
}}
/>
);
}
return (
<div className={styles.container}>
<div className={styles.mainContent}>
@ -213,12 +234,15 @@ function Login() {
</button>
<div className={styles.registerLink}>
<span>Du hast noch keinen Konto?</span>
<span>Du hast noch kein Konto?</span>
</div>
<div className={styles.ctaSection}>
<button
className={styles.textButton}
onClick={() => navigate("/register", { state: location.state })}
type="button"
className={styles.ctaPrimary}
onClick={() => navigate('/register', { state: location.state })}
>
Registrieren
Kostenlos registrieren
</button>
</div>
</div>

View file

@ -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<RegisterFormData>({
@ -34,15 +33,11 @@ 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 = "PowerOn AI Platform - Registrieren";
// Generate CSRF token for new security implementation
generateAndStoreCSRFToken();
}, []);
@ -53,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,16 +70,14 @@ function Register() {
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');
@ -96,25 +88,20 @@ function Register() {
return;
}
// Username is available, proceed with registration (no password - magic link flow)
await register(formData);
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 || {})
}
});
@ -124,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';
@ -146,7 +132,6 @@ function Register() {
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<div className={styles.loginForm}>
{/* Pending invitation notice */}
{hasPendingInvitation && !successMessage && (
<div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} />
@ -154,8 +139,8 @@ function Register() {
</div>
)}
{getErrorMessage() && (
<div className={styles.error}>{getErrorMessage()}</div>
{_getErrorMessage() && (
<div className={styles.error}>{_getErrorMessage()}</div>
)}
{successMessage && (
@ -221,7 +206,7 @@ function Register() {
onClick={handleSubmit}
disabled={isLoading || isChecking}
>
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : "Registrieren"}
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'}
</button>
</>
)}

View file

@ -1,18 +1,32 @@
/**
* Settings Page
*
* Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext).
* Settings Page User-level settings with tabs.
* Route: /settings
*/
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../providers/language/LanguageContext';
import { useCurrentUser, useUser } from '../hooks/useUsers';
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import { useApiRequest } from '../hooks/useApi';
import styles from './Settings.module.css';
// =============================================================================
// TYPES
// =============================================================================
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'profile', label: 'Profil' },
{ key: 'appearance', label: 'Darstellung' },
{ key: 'voice', label: 'Stimme & Sprache' },
{ key: 'neutralization', label: 'Neutralisierung (lokal)' },
{ key: 'privacy', label: 'Datenschutz' },
];
// =============================================================================
// PROFILE EDIT MODAL
// =============================================================================
@ -27,39 +41,13 @@ interface ProfileEditModalProps {
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Define editable profile fields
const profileAttributes: AttributeDefinition[] = [
{
name: 'fullName',
type: 'string',
label: 'Vollständiger Name',
description: 'Ihr vollständiger Name',
required: false,
placeholder: 'Max Mustermann'
},
{
name: 'email',
type: 'email',
label: 'E-Mail-Adresse',
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
required: true,
placeholder: 'name@example.com'
},
{
name: 'language',
type: 'select',
label: 'Sprache',
description: 'Anzeigesprache der Anwendung',
required: true,
options: [
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }
]
}
{ name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' },
{ name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' },
{ name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] },
];
const handleSubmit = async (formData: any) => {
setIsSaving(true);
setError(null);
@ -72,9 +60,9 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
@ -84,21 +72,358 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
</div>
<div className={styles.modalBody}>
{error && <div className={styles.errorMessage}>{error}</div>}
<FormGeneratorForm
attributes={profileAttributes}
data={userData}
mode="edit"
onSubmit={handleSubmit}
onCancel={onClose}
submitButtonText={isSaving ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? 'Speichern...' : 'Speichern'} cancelButtonText="Abbrechen" />
</div>
</div>
</div>
);
};
// =============================================================================
// VOICE SETTINGS TAB
// =============================================================================
interface VoiceMapEntry { language: string; voiceName: string; }
const VoiceSettingsTab: React.FC = () => {
const { request } = useApiRequest();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [sttLanguage, setSttLanguage] = useState('de-DE');
const [languages, setLanguages] = useState<any[]>([]);
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
const [addLanguage, setAddLanguage] = useState('de-DE');
const [addVoices, setAddVoices] = useState<any[]>([]);
const [addVoiceName, setAddVoiceName] = useState('');
const [loadingVoices, setLoadingVoices] = useState(false);
const _loadSettings = useCallback(async () => {
setLoading(true);
try {
const [prefsData, languagesData] = await Promise.all([
request({ url: '/api/voice/preferences', method: 'get' }),
request({ url: '/api/voice/languages', method: 'get' }),
]);
const langList = (languagesData as any)?.languages || [];
setLanguages(langList);
const prefs = prefsData as any;
setSttLanguage(prefs?.sttLanguage || 'de-DE');
const map: Record<string, any> = prefs?.ttsVoiceMap || {};
const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({
language: lang,
voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '',
}));
setVoiceMap(entries);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden der Voice-Einstellungen');
} finally {
setLoading(false);
}
}, [request]);
useEffect(() => { _loadSettings(); }, [_loadSettings]);
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
setLoadingVoices(true);
try {
const result = await request({ url: '/api/voice/voices', method: 'get', params: { language: lang } });
setAddVoices((result as any)?.voices || []);
setAddVoiceName('');
} catch { setAddVoices([]); }
finally { setLoadingVoices(false); }
}, [request]);
useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]);
const _handleAddEntry = useCallback(() => {
if (!addLanguage) return;
const exists = voiceMap.some(e => e.language === addLanguage);
if (exists) {
setVoiceMap(prev => prev.map(e => e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e));
} else {
setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]);
}
setAddVoiceName('');
}, [addLanguage, addVoiceName, voiceMap]);
const _handleRemoveEntry = useCallback((lang: string) => {
setVoiceMap(prev => prev.filter(e => e.language !== lang));
}, []);
const _handleSave = useCallback(async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
const mapObj: Record<string, any> = {};
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
await request({
url: '/api/voice/preferences',
method: 'put',
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
});
setSuccess('Einstellungen gespeichert');
setTimeout(() => setSuccess(null), 3000);
await _loadSettings();
} catch (err: any) {
setError(err.message || 'Fehler beim Speichern');
} finally {
setSaving(false);
}
}, [request, voiceMap, sttLanguage, _loadSettings]);
const _handleTestVoice = useCallback(async (lang: string, voice: string) => {
setTesting(lang);
try {
const result: any = await request({
url: '/api/voice/test',
method: 'post',
data: { language: lang, voiceId: voice || undefined },
});
if (result?.success && result?.audio) {
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
audio.play();
}
} catch { setError('Stimmtest fehlgeschlagen'); }
finally { setTesting(null); }
}, [request]);
const _getLanguageName = useCallback((code: string) => {
const found = languages.find((l: any) => (l.code || l) === code);
return found?.name || found?.code || code;
}, [languages]);
const _defaultLangs = [
{ code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' },
{ code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' },
{ code: 'es-ES', name: 'Espanol' },
];
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Einstellungen werden geladen...</div>;
return (
<>
{error && <div className={styles.errorMessage}>{error}</div>}
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>STT-Sprache (Spracheingabe)</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>Sprache fuer Spracherkennung</label>
<p className={styles.settingDescription}>Wird fuer die Sprache-zu-Text-Erkennung verwendet.</p>
</div>
<div className={styles.settingControl}>
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
{_displayLanguages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
))}
</select>
</div>
</div>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>TTS-Stimmen (Sprachausgabe)</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
</p>
{voiceMap.length === 0 ? (
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead><tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}><th style={{ textAlign: 'left', padding: '0.5rem' }}>Sprache</th><th style={{ textAlign: 'left', padding: '0.5rem' }}>Stimme</th><th /><th /></tr></thead>
<tbody>
{voiceMap.map(entry => (
<tr key={entry.language} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
<td style={{ padding: '0.5rem' }}>{_getLanguageName(entry.language)}</td>
<td style={{ padding: '0.5rem' }}>{entry.voiceName || 'Standard'}</td>
<td style={{ padding: '0.5rem' }}>
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }} onClick={() => _handleTestVoice(entry.language, entry.voiceName)} disabled={testing === entry.language}>
{testing === entry.language ? '...' : 'Test'}
</button>
</td>
<td style={{ padding: '0.5rem' }}>
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }} onClick={() => _handleRemoveEntry(entry.language)}>Entfernen</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Sprache</label>
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
{_displayLanguages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
))}
</select>
</div>
<div>
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Stimme</label>
<select className={styles.select} value={addVoiceName} onChange={e => setAddVoiceName(e.target.value)} disabled={loadingVoices}>
<option value="">Standard</option>
{addVoices.map((v: any) => (
<option key={v.name || v} value={v.name || v}>{v.displayName || v.name || v}</option>
))}
</select>
</div>
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>Zuweisen</button>
<button className={styles.button} onClick={() => _handleTestVoice(addLanguage, addVoiceName)} disabled={testing !== null} style={{ padding: '0.5rem 1rem' }}>
{testing === addLanguage ? '...' : 'Testen'}
</button>
</div>
</section>
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</>
);
};
// =============================================================================
// NEUTRALIZATION MAPPINGS TAB
// =============================================================================
interface NeutralizationMapping {
id: string;
originalText: string;
patternType: string;
fileId?: string;
featureInstanceId?: string;
}
const NeutralizationMappingsTab: React.FC = () => {
const { request } = useApiRequest();
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const _load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result: any = await request({ url: '/api/local/neutralization-mappings', method: 'get' });
const items = (result?.mappings || []).map((m: any) => ({
id: m.id,
originalText: m.originalText || '',
patternType: m.patternType || '',
fileId: m.fileId,
featureInstanceId: m.featureInstanceId,
}));
setMappings(items);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden');
} finally {
setLoading(false);
}
}, [request]);
useEffect(() => { _load(); }, [_load]);
const _handleDelete = useCallback(async (id: string) => {
try {
await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' });
setMappings(prev => prev.filter(m => m.id !== id));
} catch (err: any) {
setError(err.message || 'Fehler beim Loeschen');
}
}, [request]);
const _maskText = (text: string) => {
if (text.length <= 4) return '****';
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
};
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Mappings werden geladen...</div>;
return (
<>
{error && <div className={styles.errorMessage}>{error}</div>}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Platzhalter-Mappings (lokal)</h2>
<div
style={{
marginBottom: '1rem',
padding: '0.75rem 1rem',
background: 'var(--surface-color, #eff6ff)',
border: '1px solid var(--border-color, #bfdbfe)',
borderRadius: 8,
fontSize: '0.85rem',
lineHeight: 1.5,
color: 'var(--text-primary, #1e3a5f)',
}}
>
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
<strong>Mandant AI-Workspace-Instanz Einstellungen Tab Neutralisierung</strong> (nicht auf dieser
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
</div>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
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). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings hier einsehbar und loeschbar.
</p>
{mappings.length === 0 ? (
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
Keine Neutralisierungs-Mappings vorhanden.
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Platzhalter-ID</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Originaltext</th>
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Typ</th>
<th />
</tr>
</thead>
<tbody>
{mappings.map(m => (
<tr key={m.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
<td style={{ padding: '0.5rem', fontFamily: 'monospace', fontSize: '0.75rem' }}>{m.id.slice(0, 12)}...</td>
<td style={{ padding: '0.5rem' }}>{_maskText(m.originalText)}</td>
<td style={{ padding: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: '#f3f4f6' }}>
{m.patternType}
</span>
</td>
<td style={{ padding: '0.5rem' }}>
<button
className={styles.button}
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}
onClick={() => _handleDelete(m.id)}
>
Loeschen
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
);
};
// =============================================================================
// SETTINGS PAGE
// =============================================================================
@ -107,266 +432,142 @@ export const SettingsPage: React.FC = () => {
const { currentLanguage, setLanguage } = useLanguage();
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
const { updateUser } = useUser();
const [theme, setTheme] = useState<'light' | 'dark'>(
() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
);
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
const [languageError, setLanguageError] = useState<string | null>(null);
// Handle theme change
const handleThemeChange = (newTheme: 'light' | 'dark') => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
if (newTheme === 'dark') {
document.documentElement.classList.add('dark-theme');
document.documentElement.classList.remove('light-theme');
} else {
document.documentElement.classList.add('light-theme');
document.documentElement.classList.remove('dark-theme');
}
if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); }
else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); }
document.documentElement.setAttribute('data-theme', newTheme);
};
// Handle language change - save to backend and update cache
const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => {
if (!currentUser?.id || !currentUser?.username) return;
setIsSavingLanguage(true);
setLanguageError(null);
try {
// 1. Build full user object for update (backend requires full User model)
const userUpdateData = {
id: currentUser.id,
username: currentUser.username,
email: currentUser.email,
fullName: currentUser.fullName,
language: newLanguage,
enabled: currentUser.enabled ?? true,
authenticationAuthority: currentUser.authenticationAuthority || 'local'
};
// 2. Save to backend
await updateUser(currentUser.id, userUpdateData);
// 3. Update sessionStorage cache
await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: currentUser.email, fullName: currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
const cachedUser = getUserDataCache();
if (cachedUser) {
setUserDataCache({ ...cachedUser, language: newLanguage });
}
// 4. Update UI language context
if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage });
setLanguage(newLanguage);
// 5. Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
console.log('Language updated successfully to:', newLanguage);
} catch (err: any) {
console.error('Failed to update language:', err);
setLanguageError('Sprache konnte nicht gespeichert werden');
} finally {
setIsSavingLanguage(false);
}
} catch { setLanguageError('Sprache konnte nicht gespeichert werden'); }
finally { setIsSavingLanguage(false); }
}, [currentUser, updateUser, setLanguage]);
// Handle profile save
const handleProfileSave = useCallback(async (formData: any) => {
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
// Get the new language (from form or current user)
const newLanguage = formData.language || currentUser.language || 'de';
// Build full user object for update (backend requires full User model)
const userUpdateData = {
id: currentUser.id,
username: currentUser.username,
email: formData.email || currentUser.email,
fullName: formData.fullName || currentUser.fullName,
language: newLanguage,
enabled: currentUser.enabled ?? true,
authenticationAuthority: currentUser.authenticationAuthority || 'local'
};
// Update user via API
const updatedUser = await updateUser(currentUser.id, userUpdateData);
// Update sessionStorage cache
const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
const cachedUser = getUserDataCache();
if (cachedUser) {
setUserDataCache({
...cachedUser,
fullName: updatedUser.fullName || cachedUser.fullName,
email: updatedUser.email || cachedUser.email,
language: newLanguage
});
}
// Update UI language if changed
if (newLanguage !== currentLanguage) {
setLanguage(newLanguage as 'de' | 'en' | 'fr');
}
// Refetch user data
if (refetchUser) {
await refetchUser();
}
// Dispatch event to notify other components (e.g., sidebar)
if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage });
if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr');
if (refetchUser) await refetchUser();
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
return (
<div className={styles.settings}>
<header className={styles.header}>
<h1>Einstellungen</h1>
<p className={styles.subtitle}>Persönliche Einstellungen und Präferenzen</p>
<p className={styles.subtitle}>Persoenliche Einstellungen und Praeferenzen</p>
</header>
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
{_TABS.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent',
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400,
color: activeTab === tab.key ? 'var(--primary-color, #2563eb)' : 'var(--text-secondary, #888)',
}}>
{tab.label}
</button>
))}
</nav>
<main className={styles.content}>
{/* Darstellung */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Darstellung</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>Theme</label>
<p className={styles.settingDescription}>
Wähle zwischen hellem und dunklem Design.
</p>
</div>
<div className={styles.settingControl}>
<div className={styles.themeToggle}>
<button
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`}
onClick={() => handleThemeChange('light')}
>
Hell
</button>
<button
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`}
onClick={() => handleThemeChange('dark')}
>
Dunkel
</button>
{activeTab === 'profile' && (
<>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Konto</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>Profil bearbeiten</label>
<p className={styles.settingDescription}>Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.</p>
</div>
<div className={styles.settingControl}>
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>Profil oeffnen</button>
</div>
</div>
{currentUser && (
<div className={styles.userInfoCard}>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Benutzername</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Name</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>E-Mail</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
</div>
)}
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Ueber</h2>
<div className={styles.infoCard}>
<div className={styles.infoRow}><span className={styles.infoLabel}>Version</span><span className={styles.infoValue}>2.0.0</span></div>
<div className={styles.infoRow}><span className={styles.infoLabel}>Build</span><span className={styles.infoValue}>2026.03.23</span></div>
</div>
</section>
</>
)}
{activeTab === 'appearance' && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Darstellung</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>Waehlen Sie zwischen hellem und dunklem Design.</p></div>
<div className={styles.settingControl}>
<div className={styles.themeToggle}>
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>Hell</button>
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>Dunkel</button>
</div>
</div>
</div>
</div>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>Sprache</label>
<p className={styles.settingDescription}>
Wähle die Anzeigesprache der Anwendung.
{languageError && <span className={styles.errorText}> {languageError}</span>}
</p>
</div>
<div className={styles.settingControl}>
<select
className={styles.select}
value={currentLanguage}
onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')}
disabled={isSavingLanguage}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
</select>
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
</div>
</div>
</section>
{/* Konto */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Konto</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>Profil bearbeiten</label>
<p className={styles.settingDescription}>
Ändere deinen Namen und E-Mail-Adresse.
</p>
</div>
<div className={styles.settingControl}>
<button
className={styles.button}
onClick={async () => {
await refetchUser();
setIsProfileModalOpen(true);
}}
>
Profil öffnen
</button>
</div>
</div>
{/* Current user info display */}
{currentUser && (
<div className={styles.userInfoCard}>
<div className={styles.userInfoRow}>
<span className={styles.userInfoLabel}>Benutzername</span>
<span className={styles.userInfoValue}>{currentUser.username}</span>
</div>
<div className={styles.userInfoRow}>
<span className={styles.userInfoLabel}>Name</span>
<span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span>
</div>
<div className={styles.userInfoRow}>
<span className={styles.userInfoLabel}>E-Mail</span>
<span className={styles.userInfoValue}>{currentUser.email || '-'}</span>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>Anzeigesprache</label><p className={styles.settingDescription}>Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
<div className={styles.settingControl}>
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')} disabled={isSavingLanguage}>
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
</select>
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
</div>
</div>
)}
</section>
{/* Datenschutz */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Datenschutz</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>GDPR / Privacy</label>
<p className={styles.settingDescription}>
Data export, portability and account deletion.
</p>
</section>
)}
{activeTab === 'voice' && <VoiceSettingsTab />}
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
{activeTab === 'privacy' && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Datenschutz</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Feature-Daten (z.&nbsp;B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich
nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export,
Löschung) finden Sie unter GDPR.
</p>
<div className={styles.settingRow}>
<div className={styles.settingInfo}><label className={styles.settingLabel}>GDPR / Privacy</label><p className={styles.settingDescription}>Datenexport, Portabilität und Kontolöschung.</p></div>
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR öffnen</Link></div>
</div>
<div className={styles.settingControl}>
<Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">
Open GDPR page
</Link>
</div>
</div>
</section>
{/* Info */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Über</h2>
<div className={styles.infoCard}>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Version</span>
<span className={styles.infoValue}>2.0.0</span>
</div>
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Build</span>
<span className={styles.infoValue}>2026.01.20</span>
</div>
</div>
</section>
</section>
)}
</main>
{/* Profile Edit Modal */}
<ProfileEditModal
isOpen={isProfileModalOpen}
onClose={() => setIsProfileModalOpen(false)}
userData={currentUser}
onSave={handleProfileSave}
/>
<ProfileEditModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
</div>
);
};

View file

@ -29,6 +29,52 @@
font-size: 0.9375rem;
}
/* Subscription Banner */
.subscriptionBanner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
border-radius: 8px;
font-size: 0.8125rem;
background: var(--info-bg, #eff6ff);
border: 1px solid var(--info-border, #bfdbfe);
color: var(--info-color, #1e40af);
}
.bannerSeparator::before {
content: '|';
margin-right: 0.25rem;
opacity: 0.4;
}
/* Mandate Select */
.mandateSelect {
width: 100%;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
font-size: 0.8125rem;
background: var(--surface-color, #ffffff);
color: var(--text-primary, #1a1a1a);
appearance: auto;
}
.mandateSelect:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mandateHint {
margin: 0 0 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
font-style: italic;
}
/* Grid */
.grid {
display: grid;
@ -120,8 +166,54 @@
background: currentColor;
}
/* Instance List */
.instanceList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.instanceRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.instanceInfo {
min-width: 0;
overflow: hidden;
}
.deactivateButtonSmall {
flex-shrink: 0;
padding: 0.25rem 0.625rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
color: var(--text-secondary, #666);
}
.deactivateButtonSmall:hover:not(:disabled) {
border-color: var(--error-color, #dc2626);
color: var(--error-color, #dc2626);
background: var(--error-bg, #fef2f2);
}
.deactivateButtonSmall:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Actions */
.cardActions {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
@ -243,17 +335,35 @@
border-top-color: var(--border-dark, #333);
}
:global(.dark-theme) .deactivateButton {
:global(.dark-theme) .deactivateButton,
:global(.dark-theme) .deactivateButtonSmall {
border-color: var(--border-dark, #444);
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .deactivateButton:hover:not(:disabled) {
:global(.dark-theme) .deactivateButton:hover:not(:disabled),
:global(.dark-theme) .deactivateButtonSmall:hover:not(:disabled) {
border-color: var(--error-color-dark, #f87171);
color: var(--error-color-dark, #f87171);
background: rgba(248, 113, 113, 0.1);
}
:global(.dark-theme) .subscriptionBanner {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.25);
color: var(--primary-light, #93bbfc);
}
:global(.dark-theme) .mandateSelect {
background: var(--surface-dark, #1a1a1a);
border-color: var(--border-dark, #444);
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .mandateHint {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .error {
background: var(--error-bg-dark, #450a0a);
border-color: var(--error-border-dark, #991b1b);

View file

@ -1,16 +1,15 @@
/**
* Store Page
*
* Feature Store where users can self-activate features in the root mandate.
* Uses the Shared Instance Pattern -- each feature has one shared instance,
* and users get their own FeatureAccess + user-role upon activation.
* Feature Store -- Users activate feature instances in their own mandates.
* Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance
* in the selected mandate. Explicit mandate selection required.
*/
import React from 'react';
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore';
import type { StoreFeature } from '../api/storeApi';
import type { StoreFeature, UserMandate } from '../api/storeApi';
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = {
@ -62,23 +61,27 @@ function _getDescription(featureCode: string, lang: string): string {
interface FeatureCardProps {
feature: StoreFeature;
language: string;
mandates: UserMandate[];
actionLoading: string | null;
onActivate: (code: string) => void;
onDeactivate: (code: string) => void;
onActivate: (code: string, mandateId?: string) => void;
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
}
const FeatureCard: React.FC<FeatureCardProps> = ({
feature,
language,
mandates,
actionLoading,
onActivate,
onDeactivate,
}) => {
const isProcessing = actionLoading === feature.featureCode;
const icon = FEATURE_ICONS[feature.featureCode];
const activeInstances = feature.instances.filter(inst => inst.isActive);
const hasActive = activeInstances.length > 0;
return (
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}>
<div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
<div className={styles.cardHeader}>
{icon && <span className={styles.cardIcon}>{icon}</span>}
<h3 className={styles.cardTitle}>
@ -92,37 +95,56 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
</p>
</div>
<div>
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}>
<span className={styles.statusDot} />
{feature.isActive
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active')
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')}
</span>
</div>
{activeInstances.length > 0 && (
<div className={styles.instanceList}>
{activeInstances.map((inst) => (
<div key={inst.instanceId} className={styles.instanceRow}>
<div className={styles.instanceInfo}>
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
<span className={styles.statusDot} />
{inst.mandateName || inst.label}
</span>
</div>
<button
className={styles.deactivateButtonSmall}
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
disabled={isProcessing}
>
{isProcessing
? '...'
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
</button>
</div>
))}
</div>
)}
{activeInstances.length === 0 && (
<div>
<span className={`${styles.statusBadge} ${styles.statusInactive}`}>
<span className={styles.statusDot} />
{language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'}
</span>
</div>
)}
<div className={styles.cardActions}>
{feature.isActive ? (
{feature.canActivate && mandates.map((m) => (
<button
className={styles.deactivateButton}
onClick={() => onDeactivate(feature.featureCode)}
key={m.id}
className={styles.activateButton}
onClick={() => onActivate(feature.featureCode, m.id)}
disabled={isProcessing}
>
{isProcessing
? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...')
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
</button>
) : (
<button
className={styles.activateButton}
onClick={() => onActivate(feature.featureCode)}
disabled={isProcessing || !feature.canActivate}
>
{isProcessing
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
: (language === 'de'
? `Aktivieren fuer ${m.label || m.name}`
: language === 'fr'
? `Activer pour ${m.label || m.name}`
: `Activate for ${m.label || m.name}`)}
</button>
)}
))}
</div>
</div>
);
@ -130,7 +152,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
const StorePage: React.FC = () => {
const { currentLanguage } = useLanguage();
const { features, loading, actionLoading, error, activate, deactivate } = useStore();
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
return (
<div className={styles.store}>
@ -145,6 +167,33 @@ const StorePage: React.FC = () => {
</p>
</div>
{subscriptionInfo && subscriptionInfo.plan && (
<div className={styles.subscriptionBanner}>
<span>Plan: <strong>{subscriptionInfo.plan}</strong></span>
{subscriptionInfo.maxFeatureInstances != null && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}
</span>
)}
{subscriptionInfo.maxDataVolumeMB != null && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '}
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
</span>
)}
{subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF
</span>
)}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
</span>
)}
</div>
)}
{error && <div className={styles.error}>{error}</div>}
{loading ? (
@ -164,6 +213,7 @@ const StorePage: React.FC = () => {
key={feature.featureCode}
feature={feature}
language={currentLanguage}
mandates={mandates}
actionLoading={actionLoading}
onActivate={activate}
onDeactivate={deactivate}

View file

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

View file

@ -14,15 +14,17 @@ import {
splitMandateAndBillingFromForm,
} from '../../utils/mandateBillingFormMerge';
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 = () => {
const navigate = useNavigate();
const { request } = useApiRequest();
const { showWarning, showSuccess } = useToast();
const { prompt, PromptDialog } = usePrompt();
const {
mandates,
columns,
@ -35,6 +37,7 @@ export const AdminMandatesPage: React.FC = () => {
handleCreate,
handleUpdate,
handleDelete,
handleHardDelete,
handleInlineUpdate,
updateOptimistically,
} = useAdminMandates();
@ -111,15 +114,42 @@ export const AdminMandatesPage: React.FC = () => {
setEditingBillingWarning(null);
};
// Handle delete (confirmation handled by DeleteActionButton)
// System mandates (isSystem=true) are protected from deletion
const handleDeleteMandate = async (mandate: Mandate) => {
if (mandate.isSystem) {
return; // Safety guard - should not be reachable due to disabled button
return;
}
const entered = await prompt(
`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('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 (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
@ -209,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: <FaSkullCrossbones />,
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,
@ -267,6 +306,8 @@ export const AdminMandatesPage: React.FC = () => {
</div>
)}
<PromptDialog />
{/* Edit Modal */}
{editingFormData && (
<div

View file

@ -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 = () => {
<div style={_cardStyle}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>Einladungen hinzufügen</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', margin: '0 0 16px 0' }}>
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 <em>oder</em> 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.
</p>
{/* 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)
</button>
<button
className={addMode === 'existing' ? styles.primaryButton : styles.secondaryButton}
@ -476,7 +484,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
{addMode === 'email' ? (
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
<div>
<label className={`${styles.formLabel} ${styles.required}`}>E-Mail *</label>
<label className={styles.formLabel}>E-Mail (optional)</label>
<input
className={styles.formInput}
type="email"
@ -484,8 +492,19 @@ export const AdminInvitationWizardPage: React.FC = () => {
onChange={e => setInviteeForm(p => ({ ...p, email: e.target.value }))}
placeholder="beispiel@firma.com"
/>
</div>
<div>
<label className={styles.formLabel}>Benutzername (optional)</label>
<input
className={styles.formInput}
type="text"
autoComplete="off"
value={inviteeForm.username}
onChange={e => setInviteeForm(p => ({ ...p, username: e.target.value }))}
placeholder="z. B. vorname.nachname"
/>
<p style={{ fontSize: '11px', color: 'var(--text-secondary)', marginTop: '4px' }}>
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.
</p>
</div>
{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',
}}>
<input
@ -519,7 +538,10 @@ export const AdminInvitationWizardPage: React.FC = () => {
<button
className={styles.primaryButton}
onClick={addInviteeByEmail}
disabled={!inviteeForm.email.trim() || (roles.length > 0 && inviteeForm.roleIds.length === 0)}
disabled={
(!inviteeForm.email.trim() && !inviteeForm.username.trim())
|| (roles.length > 0 && inviteeForm.roleIds.length === 0)
}
>
Hinzufügen
</button>
@ -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',
}}>
<input
@ -586,7 +608,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail</th>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail / Benutzer</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Benutzername</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Typ</th>
@ -596,14 +618,16 @@ export const AdminInvitationWizardPage: React.FC = () => {
<tbody>
{invitees.map((inv, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
<td style={{ padding: '8px' }}>{inv.email}</td>
<td style={{ padding: '8px', color: 'var(--text-secondary)' }}>{inv.isExisting ? inv.username : ''}</td>
<td style={{ padding: '8px' }}>{inv.email || '—'}</td>
<td style={{ padding: '8px', color: 'var(--text-secondary)' }}>
{inv.username || ''}
</td>
<td style={{ padding: '8px' }}>
{inv.roleIds.length > 0
? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')
: '-'}
</td>
<td style={{ padding: '8px', fontSize: '12px' }}>{inv.isExisting ? 'Bestehend' : 'Neu'}</td>
<td style={{ padding: '8px', fontSize: '12px' }}>{inv.isExisting ? 'Bestehend' : 'Neu (Einladung)'}</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
<button
onClick={() => removeInvitee(idx)}
@ -654,7 +678,8 @@ export const AdminInvitationWizardPage: React.FC = () => {
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
{invitees.map((inv, i) => (
<li key={i} style={{ marginBottom: '4px' }}>
{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(', ')}`}
</li>
))}
@ -680,7 +705,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail</th>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail / Benutzer</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail gesendet</th>
</tr>
@ -688,7 +713,11 @@ export const AdminInvitationWizardPage: React.FC = () => {
<tbody>
{dispatchResults.map((r, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
<td style={{ padding: '8px' }}>{r.email}{r.username ? ` (${r.username})` : ''}</td>
<td style={{ padding: '8px' }}>
{(r.email || '').trim() && r.username
? `${(r.email || '').trim()} (@${r.username})`
: (r.email || '').trim() || (r.username ? `@${r.username}` : '—')}
</td>
<td style={{ padding: '8px' }}>
<span style={{
padding: '2px 8px', borderRadius: '4px', fontSize: '12px',

View file

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

View file

@ -13,6 +13,9 @@ import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaT
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css';
/** Wenn false: keine neue ClickUp-Verbindung über diese Seite (Buttons inaktiv). */
const isClickupConnectionUiEnabled = false;
export const ConnectionsPage: React.FC = () => {
// 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();
@ -220,7 +224,7 @@ export const ConnectionsPage: React.FC = () => {
// Form attributes for edit modal
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked'];
const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
@ -244,7 +248,10 @@ export const ConnectionsPage: React.FC = () => {
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Verbindungen</h1>
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten (Google, Microsoft, ClickUp)</p>
<p className={styles.pageSubtitle}>
Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft
{isClickupConnectionUiEnabled ? ', ClickUp' : ''})
</p>
</div>
<div className={styles.headerActions}>
<button
@ -278,14 +285,17 @@ export const ConnectionsPage: React.FC = () => {
>
<FaMicrosoft /> Microsoft
</button>
<button
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title="ClickUp-Konto verbinden"
>
<FaTasks /> ClickUp
</button>
{isClickupConnectionUiEnabled && (
<button
type="button"
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title="ClickUp-Konto verbinden"
>
<FaTasks /> ClickUp
</button>
)}
</>
)}
</div>
@ -302,7 +312,9 @@ export const ConnectionsPage: React.FC = () => {
<FaPlug className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
<p className={styles.emptyDescription}>
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.'}
</p>
{canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
@ -320,13 +332,16 @@ export const ConnectionsPage: React.FC = () => {
>
<FaMicrosoft /> Mit Microsoft verbinden
</button>
<button
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
>
<FaTasks /> Mit ClickUp verbinden
</button>
{isClickupConnectionUiEnabled && (
<button
type="button"
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
>
<FaTasks /> Mit ClickUp verbinden
</button>
)}
</div>
)}
</div>
@ -360,7 +375,9 @@ export const ConnectionsPage: React.FC = () => {
icon: <FaLink />,
onClick: handleConnect,
title: 'Verbinden',
visible: (row: Connection) => row.status !== 'active',
visible: (row: Connection) =>
row.status !== 'active' &&
(isClickupConnectionUiEnabled || row.authority !== 'clickup'),
loading: () => isConnecting,
},
{

View file

@ -16,6 +16,7 @@ import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useResizablePanels } from '../../hooks/useResizablePanels';
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt';
import styles from '../admin/Admin.module.css';
interface UserFile {
@ -31,6 +32,7 @@ interface UserFile {
export const FilesPage: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const {
@ -142,7 +144,7 @@ export const FilesPage: React.FC = () => {
}));
cols.push({
key: '_createdBy',
key: 'sysCreatedBy',
label: 'Created By',
type: 'text' as any,
sortable: true,
@ -223,11 +225,11 @@ export const FilesPage: React.FC = () => {
};
const _handleNewFolder = useCallback(async () => {
const name = prompt('Neuer Ordnername:');
const name = await promptInput('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
if (name?.trim()) {
await handleCreateFolder(name.trim(), selectedFolderId);
}
}, [handleCreateFolder, selectedFolderId]);
}, [handleCreateFolder, selectedFolderId, promptInput]);
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
const isInSelection = selectedFiles.some(f => f.id === row.id);
@ -289,7 +291,7 @@ export const FilesPage: React.FC = () => {
}, [selectedFolderId, _tableRefetch]);
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source'];
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
@ -379,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,
@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => {
</div>
</div>
)}
<PromptDialog />
</div>
);
};

View file

@ -53,7 +53,7 @@ export const PromptsPage: React.FC = () => {
// Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => {
// Fields to hide in table view
const hiddenColumns = ['id', 'mandateId', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
const hiddenColumns = ['id', 'mandateId', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions'];
const cols = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
@ -71,9 +71,9 @@ export const PromptsPage: React.FC = () => {
fkDisplayField: (attr as any).fkDisplayField,
}));
// Add _createdBy column with FK resolution to show username
// Add sysCreatedBy column with FK resolution to show username
cols.push({
key: '_createdBy',
key: 'sysCreatedBy',
label: 'Created By',
type: 'text' as any,
sortable: true,
@ -148,7 +148,7 @@ export const PromptsPage: React.FC = () => {
// Form attributes for create/edit modal
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'isSystem', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
const excludedFields = ['id', 'mandateId', 'isSystem', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);

View file

@ -101,7 +101,7 @@
margin: 0;
}
.billingModel {
.mandateSubtitle {
font-size: 0.75rem;
color: var(--text-secondary, #888);
background: var(--bg-secondary, #2a2a2a);

View file

@ -85,10 +85,11 @@ interface SettingsEditorProps {
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
const [formData, setFormData] = useState({
billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'],
defaultUserCredit: Number(settings?.defaultUserCredit ?? 0),
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,10 +97,11 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
useEffect(() => {
if (settings) {
setFormData({
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true,
autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
});
}
}, [settings]);
@ -130,32 +132,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
)}
<form onSubmit={handleSubmit}>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Abrechnungsmodell</label>
<select
className={styles.select}
value={formData.billingModel}
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as BillingSettings['billingModel'] }))}
>
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
</select>
</div>
<div className={styles.formGroup}>
<label>Standard-Guthaben (CHF)</label>
<input
type="number"
className={styles.input}
value={formData.defaultUserCredit}
onChange={(e) => setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))}
min="0"
step="0.01"
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Warnschwelle (%)</label>
@ -184,6 +160,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
</label>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formGroup} style={{ gridColumn: '1 / -1' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.autoRechargeEnabled}
onChange={(e) => setFormData(prev => ({ ...prev, autoRechargeEnabled: e.target.checked }))}
/>
Auto-Nachladung (AI-Guthaben bei niedrigem Stand)
</label>
</div>
</div>
{formData.autoRechargeEnabled && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag pro Nachladung (CHF)</label>
<input
type="number"
className={styles.input}
value={formData.rechargeAmountCHF}
onChange={(e) =>
setFormData(prev => ({ ...prev, rechargeAmountCHF: Number(e.target.value) }))
}
min="0.01"
step="0.01"
/>
</div>
<div className={styles.formGroup}>
<label>Max. Nachladungen / Monat</label>
<input
type="number"
className={styles.input}
value={formData.rechargeMaxPerMonth}
onChange={(e) =>
setFormData(prev => ({ ...prev, rechargeMaxPerMonth: Math.max(0, Math.floor(Number(e.target.value))) }))
}
min="0"
step="1"
/>
</div>
</div>
)}
<button
type="submit"
@ -202,28 +221,15 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
// ============================================================================
interface CreditAdderProps {
settings: BillingSettings | null;
accounts: AccountSummary[];
users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
}
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
const accountsByUserId = accounts
.filter(acc => acc.accountType === 'USER')
.reduce((map, acc) => {
if (acc.userId) map[acc.userId] = acc;
return map;
}, {} as Record<string, AccountSummary>);
const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const numAmount = parseFloat(amount);
@ -236,7 +242,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
setMessage(null);
try {
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
await onAddCredit(undefined, numAmount, description);
const label = numAmount > 0
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
@ -260,31 +266,6 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
)}
<form onSubmit={_handleSubmit}>
{isPrepayUser && (
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Benutzer</label>
<select
className={styles.select}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
required
>
<option value="">-- Benutzer wählen --</option>
{users.map((user) => {
const account = accountsByUserId[user.id];
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
return (
<option key={user.id} value={user.id}>
{user.displayName || user.username || user.id}{balanceInfo}
</option>
);
})}
</select>
</div>
</div>
)}
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>Betrag (CHF)</label>
@ -313,7 +294,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
disabled={saving || !amount}
>
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
</button>
@ -328,11 +309,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
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<AccountsOverviewProps> = ({ accounts, users, loading }) => {
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', {
style: 'currency',
@ -340,19 +322,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
}).format(amount);
};
// Build a lookup map: userId -> display name
const _userNameMap = useMemo(() => {
const map = new Map<string, string>();
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 <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
}
@ -360,16 +331,19 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
if (accounts.length === 0) {
return <div className={styles.noData}>Keine Konten vorhanden</div>;
}
if (poolAccounts.length === 0) {
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
}
return (
<div className={styles.adminSection}>
<h3>Konten</h3>
<div className={styles.accountsGrid}>
{accounts.map((account) => (
{poolAccounts.map((account) => (
<div key={account.id} className={styles.accountCard}>
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
<h4>Mandanten-Konto</h4>
<div className={styles.accountInfo}>
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div>
@ -631,6 +605,7 @@ export const BillingAdmin: React.FC = () => {
const [adminTab, setAdminTab] = useState<AdminTabType>(
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
);
const [subscriptionTabKey, setSubscriptionTabKey] = useState(0);
useEffect(() => {
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
@ -701,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',
@ -762,7 +737,7 @@ export const BillingAdmin: React.FC = () => {
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
Guthaben
</button>
<button onClick={() => setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
<button onClick={() => { setAdminTab('subscription'); setSubscriptionTabKey(k => k + 1); }} style={_tabStyle(adminTab === 'subscription')}>
Abonnement
</button>
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
@ -782,9 +757,6 @@ export const BillingAdmin: React.FC = () => {
<>
{isSysAdmin && (
<CreditAdder
settings={settings}
accounts={accounts}
users={users}
onAddCredit={_handleAddCredit}
/>
)}
@ -798,7 +770,7 @@ export const BillingAdmin: React.FC = () => {
)}
{adminTab === 'subscription' && (
<SubscriptionTab mandateId={selectedMandateId} />
<SubscriptionTab key={subscriptionTabKey} mandateId={selectedMandateId} />
)}
{adminTab === 'transactions' && (

View file

@ -26,11 +26,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
return (
<div
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
@ -38,7 +33,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
</div>
<div className={styles.balanceAmount}>
{formatCurrency(balance.balance)}

View file

@ -8,19 +8,16 @@
*/
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';
import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
import { getUserDataCache } from '../../utils/userCache';
import { UserTransaction } from '../../api/billingApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
// ============================================================================
// HELPER: Currency formatter
// ============================================================================
@ -46,34 +43,51 @@ 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
// ============================================================================
interface BalanceCardProps {
balance: BillingBalance;
onCheckout?: (mandateId: string, amount: number) => void;
checkoutLoading?: boolean;
onOpenMandateAdmin?: (mandateId: string) => void;
}
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => {
const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]);
const [showCheckout, setShowCheckout] = useState(false);
const _getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
// Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing.
const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER';
const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE';
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
{onOpenMandateAdmin ? (
<button
type="button"
className={styles.mandateName}
onClick={() => onOpenMandateAdmin(balance.mandateId)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
color: 'inherit',
textDecoration: 'underline',
}}
>
{balance.mandateName}
</button>
) : (
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
)}
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(balance.balance)}
@ -83,60 +97,17 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
Niedriges Guthaben
</div>
)}
{isMandatePrepaidPool && (
<p
style={{
marginTop: '12px',
fontSize: '13px',
lineHeight: 1.45,
opacity: 0.75,
marginBottom: 0,
}}
>
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing).
</p>
)}
{canStripeTopUpHere && onCheckout && (
<div style={{ marginTop: '12px' }}>
{!showCheckout ? (
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(true)}
>
Budget laden mit Kreditkarte
</button>
) : (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<select
className={styles.select}
value={selectedAmount}
onChange={(e) => setSelectedAmount(Number(e.target.value))}
style={{ flex: 1, fontSize: '13px' }}
>
{STRIPE_AMOUNT_PRESETS.map((preset) => (
<option key={preset} value={preset}>{preset} CHF</option>
))}
</select>
<button
className={`${styles.button} ${styles.buttonPrimary}`}
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
disabled={checkoutLoading}
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
>
{checkoutLoading ? 'Laden...' : 'Zahlen'}
</button>
<button
className={`${styles.button} ${styles.buttonSecondary || ''}`}
style={{ fontSize: '13px', padding: '6px 12px' }}
onClick={() => setShowCheckout(false)}
>
&times;
</button>
</div>
)}
</div>
)}
<p
style={{
marginTop: '12px',
fontSize: '13px',
lineHeight: 1.45,
opacity: 0.75,
marginBottom: 0,
}}
>
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing).
</p>
</div>
);
};
@ -157,7 +128,7 @@ const TabNav: React.FC<TabNavProps> = ({ 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',
@ -329,9 +300,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [searchParams, setSearchParams] = useSearchParams();
const { request } = useApiRequest();
const [checkoutLoading, setCheckoutLoading] = useState(false);
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<string>('personal');
@ -399,58 +373,20 @@ export const BillingDataView: React.FC = () => {
setCheckoutMessage(null);
}, [searchParams, setSearchParams]);
const _handleCheckout = useCallback(async (mandateId: string, amount: number) => {
setCheckoutLoading(true);
setCheckoutMessage(null);
try {
const currentUser = getUserDataCache();
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete('success');
currentUrl.searchParams.delete('canceled');
currentUrl.searchParams.delete('session_id');
currentUrl.hash = '';
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
const result = await createCheckoutSession(request, mandateId, {
userId: currentUser?.id,
amount,
returnUrl,
});
if (result?.redirectUrl) {
window.location.href = result.redirectUrl;
}
} catch (err: any) {
setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' });
setCheckoutLoading(false);
}
}, [request]);
// All user balances (for admin overview cards)
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
// Statistics state (shared by Overview and Statistics tabs)
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Storage volume state (for Statistics tab)
const [storageData, setStorageData] = useState<DataVolumeInfo[]>([]);
const [storageLoading, setStorageLoading] = useState(false);
// Transactions state (for Transactions tab)
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
const [transactionsLoading, setTransactionsLoading] = useState(false);
const [transactionsError, setTransactionsError] = useState<string | null>(null);
const [transactionsPagination, setTransactionsPagination] = useState<any>(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 {
@ -486,15 +422,47 @@ export const BillingDataView: React.FC = () => {
_loadViewStatistics(period, year, month);
}, [_loadViewStatistics]);
// Initial data load: load statistics when overview or statistics tab becomes active
// Load storage volume for all accessible mandates
const _loadStorageData = useCallback(async () => {
const mandateIds = new Set<string>();
for (const b of balances) {
if (selectedScope === 'personal' || selectedScope === 'all' || selectedScope === b.mandateId) {
mandateIds.add(b.mandateId);
}
}
if (mandateIds.size === 0) {
setStorageData([]);
return;
}
setStorageLoading(true);
try {
const mandateNameMap = new Map(balances.map(b => [b.mandateId, b.mandateName]));
const results = await Promise.all(
Array.from(mandateIds).map(async (mid) => {
try {
const resp = await api.get(`/api/subscription/data-volume/${mid}`);
return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo;
} catch {
return null;
}
})
);
setStorageData(results.filter((r): r is DataVolumeInfo => r !== null));
} catch {
setStorageData([]);
} finally {
setStorageLoading(false);
}
}, [balances, selectedScope]);
// Initial data load
useEffect(() => {
if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear());
_loadStorageData();
}
if (activeTab === 'overview') {
_loadAllUserBalances();
}
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
}, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]);
// Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => {
@ -644,12 +612,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 (
<>
@ -666,35 +628,60 @@ export const BillingDataView: React.FC = () => {
<BalanceCard
key={balance.mandateId}
balance={balance}
onCheckout={_handleCheckout}
checkoutLoading={checkoutLoading}
onOpenMandateAdmin={_openMandateBillingAdmin}
/>
))}
</div>
)}
</section>
{/* All User Balance Cards (mandate/all scope) */}
{filteredUserBalances.length > 0 && (
{/* Storage quick info */}
{!storageLoading && storageData.length > 0 && (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
{allUserBalancesLoading ? (
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div>
) : (
<div className={styles.balanceGrid}>
{filteredUserBalances.map((ub, idx) => (
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
<div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
<span className={styles.billingModel}>{ub.mandateName}</span>
</div>
<div className={styles.balanceAmount}>
{_formatCurrency(ub.balance || 0)}
<h2 className={styles.sectionTitle}>Speicher</h2>
<div className={styles.balanceGrid}>
{storageData.map((sv) => {
const pct = sv.percentUsed ?? 0;
const barColor = pct >= 90
? 'var(--color-error, #ef4444)'
: pct >= 70
? 'var(--color-warning, #f59e0b)'
: 'var(--primary-color, #F25843)';
return (
<div key={sv.mandateId} className={styles.balanceCard}>
<h3 className={styles.mandateName}>{sv.mandateName}</h3>
<div className={styles.balanceAmount} style={{ fontSize: '1.3rem' }}>
{formatBinaryDataSizeFromMebibytes(sv.usedMB)}
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', marginLeft: '6px' }}>
/ {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : ''}
</span>
</div>
{sv.maxDataVolumeMB != null && (
<div style={{
height: '6px',
background: 'var(--bg-secondary, #2a2a2a)',
borderRadius: '3px',
overflow: 'hidden',
marginTop: '10px',
}}>
<div style={{
height: '100%',
width: `${Math.min(pct, 100)}%`,
background: barColor,
borderRadius: '3px',
minWidth: pct > 0 ? '3px' : '0',
}} />
</div>
)}
{sv.warning && (
<div className={styles.warningBadge} style={{ marginTop: '8px' }}>
Speicher knapp
</div>
)}
</div>
))}
</div>
)}
);
})}
</div>
</section>
)}
@ -716,18 +703,104 @@ export const BillingDataView: React.FC = () => {
{/* Tab: Statistik (Dashboard) */}
{/* ================================================================ */}
{activeTab === 'statistics' && (
<section className={styles.section}>
<FormGeneratorReport
title="Nutzungsstatistik"
subtitle="Detaillierte Analyse der AI-Nutzung"
periodSelector={periodSelectorConfig}
onFilterChange={_handleStatsFilterChange}
loading={statsLoading}
sections={statisticsSections}
noDataMessage="Keine Statistiken verfügbar"
currencyCode="CHF"
/>
</section>
<>
{/* Storage volume section */}
<section className={styles.section}>
<div className={styles.statisticsChart}>
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
Speicherverbrauch
</h3>
{storageLoading ? (
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
) : storageData.length === 0 ? (
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{storageData.map((sv) => {
const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB);
const maxLabel = sv.maxDataVolumeMB != null
? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB)
: 'unbegrenzt';
const pct = sv.percentUsed ?? 0;
const barColor = pct >= 90
? 'var(--color-error, #ef4444)'
: pct >= 70
? 'var(--color-warning, #f59e0b)'
: 'var(--primary-color, #F25843)';
return (
<div key={sv.mandateId} style={{
background: 'var(--bg-secondary, #2a2a2a)',
borderRadius: '8px',
padding: '14px 16px',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
}}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
{sv.mandateName}
</span>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', fontFamily: 'monospace' }}>
{usedLabel} / {maxLabel}
{sv.percentUsed != null && (
<span style={{ marginLeft: '8px', color: barColor, fontWeight: 600 }}>
({pct.toFixed(1)}%)
</span>
)}
</span>
</div>
{sv.maxDataVolumeMB != null && (
<div style={{
height: '10px',
background: 'var(--surface-color, #1e1e1e)',
borderRadius: '5px',
overflow: 'hidden',
marginBottom: '8px',
}}>
<div style={{
height: '100%',
width: `${Math.min(pct, 100)}%`,
background: barColor,
borderRadius: '5px',
transition: 'width 0.4s ease',
minWidth: pct > 0 ? '4px' : '0',
}} />
</div>
)}
<div style={{
display: 'flex',
gap: '16px',
fontSize: '0.8rem',
color: 'var(--text-secondary, #888)',
}}>
<span>Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)}</span>
<span>RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
{/* AI usage statistics */}
<section className={styles.section}>
<FormGeneratorReport
title="Nutzungsstatistik"
subtitle="Detaillierte Analyse der AI-Nutzung"
periodSelector={periodSelectorConfig}
onFilterChange={_handleStatsFilterChange}
loading={statsLoading}
sections={statisticsSections}
noDataMessage="Keine Statistiken verfügbar"
currencyCode="CHF"
/>
</section>
</>
)}
{/* ================================================================ */}

View file

@ -38,20 +38,14 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
}).format(amount);
};
const getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
return (
<div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}>
<thead>
<tr>
<th>Mandant</th>
<th>Billing-Modell</th>
<th>Anzahl Benutzer</th>
<th>Standard-Guthaben</th>
<th>Warnschwelle (%)</th>
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
<th>Aktion</th>
</tr>
@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
>
<td>{balance.mandateName || balance.mandateId}</td>
<td>{getBillingModelLabel(balance.billingModel)}</td>
<td>{balance.userCount}</td>
<td>{formatCurrency(balance.defaultUserCredit)}</td>
<td>{balance.warningThresholdPercent}%</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
<td>
<button

View file

@ -12,6 +12,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useSubscription } from '../../hooks/useSubscription';
import { useConfirm } from '../../hooks/useConfirm';
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css';
// ============================================================================
@ -43,7 +44,7 @@ const _statusLabel: Record<string, { label: string; color: string }> = {
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' },
};
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
NONE: '—',
};
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
const storageOverageChfPerGbMonth = 0.5;
// ============================================================================
// Plan Card
// ============================================================================
@ -71,13 +75,13 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
return (
<div style={{
border: isCurrent ? '2px solid var(--color-primary, #3b82f6)' : '1px solid var(--color-border, #333)',
border: isCurrent ? '2px solid var(--primary-color, #F25843)' : '1px solid var(--color-border, var(--border-color, #333))',
borderRadius: '8px',
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
background: isCurrent ? 'rgba(59,130,246,0.06)' : 'var(--color-surface, #1a1a2e)',
background: isCurrent ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))' : 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
minWidth: 220,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -85,12 +89,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
{isCurrent && (
<span style={{
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
}}>Aktuell</span>
)}
</div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: 0 }}>
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>
{_t(plan.description)}
</p>
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
<div style={{ fontSize: '0.85rem' }}>
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary)' }}>
AI-Budget (inkl.): <strong>{_formatCurrency(plan.budgetAiCHF ?? 0)}</strong> / Periode
{' · '}
Speicher (inkl.):{' '}
<strong>
{plan.maxDataVolumeMB == null
? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</strong>
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
</div>
</div>
)}
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ 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)}</>}
</>
)}
</div>
)}
@ -115,7 +140,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ 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,
}}
@ -152,15 +177,15 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
return (
<div style={{
border: '1px solid var(--color-border, #333)',
border: '1px solid var(--color-border, var(--border-color, #333))',
borderRadius: '8px',
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
background: 'var(--color-surface, #1a1a2e)',
background: 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{label}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -204,7 +229,7 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
{!isPending && !isScheduled && (
<div style={{
fontSize: '0.85rem', color: 'var(--text-secondary, #888)',
fontSize: '0.85rem', color: 'var(--text-secondary)',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
}}>
<span>Gestartet: {_formatDate(sub.startedAt)}</span>
@ -214,6 +239,20 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
{isActive && !sub.recurring && sub.currentPeriodEnd && (
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
)}
{plan && (
<>
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span>
<span>
Speicher (inkl.):{' '}
{plan.maxDataVolumeMB == null
? 'unbegrenzt'
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
</span>
<span style={{ gridColumn: '1 / -1' }}>
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
</span>
</>
)}
</div>
)}
@ -224,7 +263,7 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ 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',
}}
>
@ -311,14 +350,21 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ 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.' });
@ -358,7 +404,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ 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',
},
);
@ -397,9 +443,9 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
{checkoutMessage && (
<div style={{
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(59,130,246,0.12)',
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6'}`,
color: checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6',
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-color, #F25843)'}`,
color: checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-light, #F25843)',
fontSize: '0.9rem',
}}>
{checkoutMessage.text}

View file

@ -110,9 +110,9 @@ export const AutomationDefinitionsView: React.FC = () => {
const columns = useMemo(() => {
const hiddenColumns = [
'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt',
'id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt',
'template', 'executionLogs', 'placeholders',
'_createdByUserName', 'mandateName', 'featureInstanceName',
'sysCreatedByUserName', 'mandateName', 'featureInstanceName',
];
const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
@ -130,7 +130,7 @@ export const AutomationDefinitionsView: React.FC = () => {
const enrichedColumns = [
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
{ key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
];
return [...attrColumns, ...enrichedColumns];
}, [attributes]);

View file

@ -52,7 +52,7 @@ export const AutomationTemplatesView: React.FC = () => {
value ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--info-color, #3182ce)', color: '#fff' }}><FaLock style={{ fontSize: '0.625rem' }} /> System</span>
: <span style={{ fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--success-color, #38a169)', color: '#fff' }}>Instanz</span>
},
{ key: '_createdByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
], []);
const handleEditClick = async (template: AutomationTemplate) => {

View file

@ -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)) {

View file

@ -1,7 +1,56 @@
/* Outer flex layout: UDB sidebar + main dossier */
.dossierLayout {
display: flex;
height: calc(100vh - 140px);
overflow: hidden;
}
.udbSidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border-color, #e0e0e0);
display: flex;
flex-direction: column;
background: var(--bg-card, #fff);
overflow: hidden;
position: relative;
transition: width 0.2s, min-width 0.2s;
}
.udbSidebarCollapsed {
width: 36px;
min-width: 36px;
}
.udbToggle {
position: absolute;
top: 8px;
right: 4px;
z-index: 2;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-card, #fff);
cursor: pointer;
font-size: 0.65rem;
color: var(--text-secondary, #888);
display: flex;
align-items: center;
justify-content: center;
}
.udbToggle:hover {
background: var(--bg-hover, #f5f5f5);
color: var(--primary-color, #F25843);
}
.dossier {
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
flex: 1;
min-width: 0;
overflow: hidden;
}
@ -357,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;

View file

@ -1,69 +1,125 @@
/**
* CommCoach Dossier View (Main View)
*
* Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents.
* Unified view per context: Coaching session, Tasks, Sessions history, Scores.
* Voice first, always with text fallback.
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useCommcoach } from '../../../hooks/useCommcoach';
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
import {
getDossierExportUrl, getSessionExportUrl,
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
getScoreHistoryApi, getPersonasApi,
type CoachingDocument, type CoachingPersona,
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';
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
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;
}
export const CommcoachDossierView: React.FC = () => {
const coach = useCommcoach();
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
interface CommcoachDossierViewProps {
persistentInstanceId?: string;
persistentMandateId?: string;
}
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({
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 [activeTab, setActiveTab] = useState<TabKey>('coaching');
const [showNewContext, setShowNewContext] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newCategory, setNewCategory] = useState('custom');
const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState<UdbTab>('files');
const [newTaskTitle, setNewTaskTitle] = useState('');
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
const [uploading, setUploading] = useState(false);
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_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;
const inputRef = useRef<HTMLTextAreaElement>(null);
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<string[]>([]);
const [debugVisible, setDebugVisible] = useState(false);
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]);
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) => {
@ -82,27 +138,14 @@ export const CommcoachDossierView: React.FC = () => {
}
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
// Load documents, scores, personas when context changes
// Load scores, personas when context changes
useEffect(() => {
if (!instanceId || !coach.selectedContextId) return;
getDocumentsApi(request, instanceId, coach.selectedContextId)
.then(d => setDocuments(d))
.catch(() => {});
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
.then(h => setScoreHistory(h))
.catch(() => {});
}, [instanceId, request, coach.selectedContextId]);
useEffect(() => {
coach.onDocumentCreatedRef.current = (doc) => {
setDocuments(prev => {
if (prev.some(d => d.id === doc.id)) return prev;
return [doc, ...prev];
});
};
return () => { coach.onDocumentCreatedRef.current = null; };
}, [coach.onDocumentCreatedRef]);
useEffect(() => {
if (!instanceId) return;
getPersonasApi(request, instanceId)
@ -110,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,14 +178,51 @@ export const CommcoachDossierView: React.FC = () => {
}
}, [activeTab, coach.session?.id, voice]);
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
useEffect(() => {
coach.onDocumentCreatedRef.current = () => {
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
};
return () => {
coach.onDocumentCreatedRef.current = null;
};
}, [coach, _refreshWorkspaceAssets]);
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(); }
@ -144,46 +241,6 @@ export const CommcoachDossierView: React.FC = () => {
coach.selectContext(contextId, { skipSessionResume: true });
}, [coach]);
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !instanceId || !coach.selectedContextId) return;
setUploading(true);
try {
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
setDocuments(prev => [doc, ...prev]);
} catch { /* upload failed */ } finally {
setUploading(false);
e.target.value = '';
}
}, [instanceId, coach.selectedContextId]);
const handleDeleteDocument = useCallback(async (docId: string) => {
if (!instanceId) return;
try {
await deleteDocumentApi(request, instanceId, docId);
setDocuments(prev => prev.filter(d => d.id !== docId));
} catch { /* delete failed */ }
}, [instanceId, request]);
const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => {
if (!doc.fileRef) return;
try {
const response = await api.get(`/api/files/${doc.fileRef}/download`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = doc.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
}
}, []);
const handleAddTask = useCallback(async () => {
if (!newTaskTitle.trim()) return;
await coach.addTask(newTaskTitle);
@ -195,7 +252,30 @@ export const CommcoachDossierView: React.FC = () => {
}
return (
<div className={styles.dossier}>
<div className={styles.dossierLayout}>
{/* UDB Sidebar */}
{_udbContext && (
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
<button
className={styles.udbToggle}
onClick={() => setUdbCollapsed(v => !v)}
title={udbCollapsed ? 'Seitenleiste einblenden' : 'Seitenleiste ausblenden'}
>
{udbCollapsed ? '\u25B6' : '\u25C0'}
</button>
{!udbCollapsed && (
<UnifiedDataBar
context={_udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
hideTabs={['chats']}
/>
)}
</div>
)}
{/* Main Content */}
<div className={styles.dossier}>
{/* Context Selector */}
<div className={styles.contextSelector}>
{coach.contexts.map(ctx => (
@ -286,13 +366,13 @@ export const CommcoachDossierView: React.FC = () => {
{/* Tab Navigation */}
<div className={styles.tabs}>
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
<button
key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)}
>
{_tabLabel(tab, coach, documents)}
{_tabLabel(tab, coach)}
</button>
))}
</div>
@ -394,6 +474,63 @@ export const CommcoachDossierView: React.FC = () => {
</div>
</AutoScroll>
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
<div className={styles.agentActivityPanel}>
<button
className={styles.agentActivityHeader}
onClick={() => setShowAgentActivity(prev => !prev)}
type="button"
>
<span className={styles.agentActivityTitle}>
Agent-Aktivität
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
</span>
<span className={styles.agentActivityStatus}>
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? 'Tool-Aufrufe vorhanden' : 'Warte auf Agent')}
</span>
<span className={styles.agentActivityChevron}>{showAgentActivity ? '▾' : '▸'}</span>
</button>
{showAgentActivity && (
<div className={styles.agentActivityBody}>
{coach.agentToolCalls.length === 0 ? (
<div className={styles.agentActivityEmpty}>
Noch keine Tool-Aufrufe in dieser Antwort.
</div>
) : (
coach.agentToolCalls.map((toolCall, idx) => (
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
<div className={styles.agentActivityItemHeader}>
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
<span
className={`${styles.agentActivityBadge} ${
toolCall.success === true
? styles.agentActivityBadgeSuccess
: toolCall.success === false
? styles.agentActivityBadgeError
: styles.agentActivityBadgeRunning
}`}
>
{toolCall.success === true ? 'fertig' : toolCall.success === false ? 'fehler' : 'läuft'}
</span>
</div>
{toolCall.args && (
<div className={styles.agentActivityMeta}>
<strong>Args:</strong> {_formatToolPayload(toolCall.args)}
</div>
)}
{toolCall.result && (
<div className={styles.agentActivityMeta}>
<strong>Result:</strong> {toolCall.result}
</div>
)}
</div>
))
)}
</div>
)}
</div>
)}
{/* Input Area */}
<div className={styles.inputArea}>
<div className={styles.voiceStatus}>
@ -411,6 +548,53 @@ export const CommcoachDossierView: React.FC = () => {
: 'Mikrofon wird gestartet...'}
</span>
</div>
{/* Attachment Chips */}
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
{attachedFileIds.map(fId => {
const file = wsFiles.find(f => f.id === fId);
return (
<span key={fId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
}}>
{file?.fileName || fId}
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
{attachedDsIds.map(dsId => {
const ds = wsDataSources.find(d => d.id === dsId);
return (
<span key={dsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}>
{ds?.label || ds?.path || dsId}
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
{attachedFdsIds.map(fdsId => {
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
return (
<span key={fdsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}>
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId}
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
</div>
)}
<div className={styles.textInputRow}>
<textarea
ref={inputRef}
@ -422,6 +606,153 @@ export const CommcoachDossierView: React.FC = () => {
rows={1}
disabled={coach.isStreaming}
/>
{/* File Picker */}
{wsFiles.length > 0 && (
<div style={{ position: 'relative' }}>
<button
onClick={() => { setShowFilePicker(v => !v); setShowSourcePicker(false); }}
disabled={coach.isStreaming}
title="Datei anhängen"
style={{
width: 36, height: 36, borderRadius: 8,
border: `1px solid ${attachedFileIds.length ? '#1565c0' : 'var(--border-color, #ddd)'}`,
background: attachedFileIds.length ? '#e3f2fd' : 'var(--secondary-bg, #f5f5f5)',
color: attachedFileIds.length ? '#1565c0' : '#666',
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
fontSize: 15, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
}}
>
+
{attachedFileIds.length > 0 && (
<span style={{ position: 'absolute', top: -4, right: -4, background: '#1565c0', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{attachedFileIds.length}
</span>
)}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 220, maxHeight: 240, overflowY: 'auto',
}}>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>Dateien anhängen</div>
{wsFiles.map(f => {
const sel = attachedFileIds.includes(f.id);
return (
<div key={f.id} onClick={() => _toggleFile(f.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#e3f2fd' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #1565c0' : '2px solid #ccc', background: sel ? '#1565c0' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.fileName}</span>
</div>
);
})}
</div>
)}
</div>
)}
{/* Source Picker */}
{(wsDataSources.length > 0 || wsFeatureDataSources.length > 0) && (
<div style={{ position: 'relative' }}>
<button
onClick={() => { setShowSourcePicker(v => !v); setShowFilePicker(false); }}
disabled={coach.isStreaming}
title="Datenquellen anhängen"
style={{
width: 36, height: 36, borderRadius: 8,
border: `1px solid ${(attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : 'var(--border-color, #ddd)'}`,
background: (attachedDsIds.length + attachedFdsIds.length) ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: (attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : '#666',
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
}}
>
&#128279;
{(attachedDsIds.length + attachedFdsIds.length) > 0 && (
<span style={{ position: 'absolute', top: -4, right: -4, background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{attachedDsIds.length + attachedFdsIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
{wsDataSources.length > 0 && (
<>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>Persönliche Quellen</div>
{wsDataSources.map(ds => {
const sel = attachedDsIds.includes(ds.id);
return (
<div key={ds.id} onClick={() => _toggleDs(ds.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #2e7d32' : '2px solid #ccc', background: sel ? '#2e7d32' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ds.label || ds.path}</span>
</div>
);
})}
</>
)}
{wsFeatureDataSources.length > 0 && (
<>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: wsDataSources.length ? '1px solid #f0f0f0' : 'none', borderBottom: '1px solid #f0f0f0' }}>Feature-Datenquellen</div>
{wsFeatureDataSources.map(fds => {
const sel = attachedFdsIds.includes(fds.id);
return (
<div key={fds.id} onClick={() => _toggleFds(fds.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #7b1fa2' : '2px solid #ccc', background: sel ? '#7b1fa2' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{fds.label} {fds.tableName}</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{/* Provider Selector */}
<ProviderMultiSelect
selection={providerSelection}
onChange={setProviderSelection}
showLabel={false}
disabled={coach.isStreaming}
/>
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
Senden
</button>
@ -546,54 +877,10 @@ export const CommcoachDossierView: React.FC = () => {
</div>
)}
{/* ============================================================ */}
{/* DOCUMENTS TAB */}
{/* ============================================================ */}
{activeTab === 'documents' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<label className={styles.uploadLabel}>
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
</label>
</div>
{documents.length === 0 ? (
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
) : (
<div className={styles.documentList}>
{documents.map(doc => (
<div key={doc.id} className={styles.documentItem}>
<div className={styles.documentInfo}>
<div className={styles.documentName}>{doc.fileName}</div>
<div className={styles.documentMeta}>
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
</div>
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
</div>
<div className={styles.documentActions}>
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</>)}
{/* #region agent log */}
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
<button
onClick={() => { setDebugSnapshot([...debugLogsRef.current]); setDebugVisible(v => !v); }}
style={{background:'#333',color:'#0f0',border:'none',padding:'4px 8px',fontSize:'10px',borderRadius:'4px 0 0 0'}}
>DBG ({debugLogsRef.current.length})</button>
{debugVisible && (
<div style={{background:'rgba(0,0,0,0.9)',color:'#0f0',fontSize:'9px',maxHeight:'40vh',overflow:'auto',padding:'4px',fontFamily:'monospace',whiteSpace:'pre-wrap',width:'100vw'}}>
{debugSnapshot.map((l,i) => <div key={i}>{l}</div>)}
</div>
)}
</div>
{/* #endregion */}
</div>
</div>
);
};
@ -607,13 +894,12 @@ function _categoryIcon(category: string): string {
return icons[category] || '*';
}
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
function _tabLabel(tab: TabKey, coach: any): string {
switch (tab) {
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
case 'sessions': return `Sessions (${coach.sessions.length})`;
case 'scores': return `Bewertungen (${coach.scores.length})`;
case 'documents': return `Dokumente (${documents.length})`;
}
}
@ -634,12 +920,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
return Object.values(groups);
}
function _formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = {
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
@ -649,4 +929,14 @@ function _dimensionLabel(dim: string): string {
return labels[dim] || dim;
}
function _formatToolPayload(payload: Record<string, unknown>): 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;

View file

@ -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<CommcoachKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
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 (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<CommcoachDossierView
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
</div>
);
};
export default CommcoachKeepAlive;

View file

@ -1,15 +1,16 @@
/**
* CommCoach Settings View
*
* User profile settings: voice preferences, reminders, email notifications.
*
* Coaching-specific settings: reminders, email notifications, stats.
* Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache").
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import {
getProfileApi, updateProfileApi,
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
type CoachingUserProfile,
} from '../../../api/commcoachApi';
import styles from './CommcoachSettingsView.module.css';
@ -19,16 +20,11 @@ export const CommcoachSettingsView: React.FC = () => {
const instanceId = useInstanceId();
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
const [languages, setLanguages] = useState<any[]>([]);
const [voices, setVoices] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [language, setLanguage] = useState('de-DE');
const [voiceId, setVoiceId] = useState('');
const [reminderEnabled, setReminderEnabled] = useState(false);
const [reminderTime, setReminderTime] = useState('09:00');
const [emailEnabled, setEmailEnabled] = useState(true);
@ -38,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => {
const loadData = async () => {
setLoading(true);
try {
const [profileData, languagesData] = await Promise.all([
getProfileApi(request, instanceId),
getVoiceLanguagesApi(request, instanceId),
]);
const profileData = await getProfileApi(request, instanceId);
setProfile(profileData);
setLanguages(languagesData || []);
if (profileData) {
setLanguage(profileData.preferredLanguage || 'de-DE');
setVoiceId(profileData.preferredVoice || '');
setReminderEnabled(profileData.dailyReminderEnabled || false);
setReminderTime(profileData.dailyReminderTime || '09:00');
setEmailEnabled(profileData.emailSummaryEnabled !== false);
}
const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE');
setVoices(voicesData || []);
} catch (err: any) {
setError(err.message || 'Fehler beim Laden');
} finally {
@ -64,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => {
loadData();
}, [request, instanceId]);
const handleLanguageChange = useCallback(async (newLang: string) => {
setLanguage(newLang);
if (!instanceId) return;
try {
const voicesData = await getVoiceVoicesApi(request, instanceId, newLang);
setVoices(voicesData || []);
setVoiceId('');
} catch { /* ignore */ }
}, [request, instanceId]);
const handleSave = useCallback(async () => {
if (!instanceId) return;
setSaving(true);
@ -81,8 +57,6 @@ export const CommcoachSettingsView: React.FC = () => {
setSuccess(null);
try {
const updated = await updateProfileApi(request, instanceId, {
preferredLanguage: language,
preferredVoice: voiceId || null,
dailyReminderEnabled: reminderEnabled,
dailyReminderTime: reminderTime,
emailSummaryEnabled: emailEnabled,
@ -95,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => {
} finally {
setSaving(false);
}
}, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]);
const handleTestVoice = useCallback(async () => {
if (!instanceId) return;
setTesting(true);
try {
const result = await testVoiceApi(request, instanceId, {
language,
voiceId: voiceId || undefined,
});
if (result.success && result.audio) {
const audioData = `data:audio/mp3;base64,${result.audio}`;
const audio = new Audio(audioData);
audio.play();
}
} catch (err: any) {
setError('Sprachtest fehlgeschlagen');
} finally {
setTesting(false);
}
}, [request, instanceId, language, voiceId]);
}, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]);
if (loading) {
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
@ -128,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => {
{error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>}
{/* Voice Settings */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Sprache und Stimme</h3>
<div className={styles.field}>
<label className={styles.label}>Sprache</label>
<select className={styles.select} value={language} onChange={e => handleLanguageChange(e.target.value)}>
{languages.length > 0 ? (
languages.map((lang: any) => (
<option key={lang.code || lang} value={lang.code || lang}>
{lang.name || lang.code || lang}
</option>
))
) : (
<>
<option value="de-DE">Deutsch</option>
<option value="en-US">English (US)</option>
<option value="fr-FR">Francais</option>
</>
)}
</select>
</div>
<div className={styles.field}>
<label className={styles.label}>Stimme</label>
<div className={styles.voiceRow}>
<select className={styles.select} value={voiceId} onChange={e => setVoiceId(e.target.value)}>
<option value="">Standard</option>
{voices.map((v: any) => (
<option key={v.name || v} value={v.name || v}>
{v.displayName || v.name || v}
</option>
))}
</select>
<button className={styles.testBtn} onClick={handleTestVoice} disabled={testing}>
{testing ? 'Teste...' : 'Testen'}
</button>
</div>
</div>
<h3 className={styles.sectionTitle}>Stimme & Sprache</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.
</p>
<Link to="/settings" onClick={() => {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")
</Link>
</div>
{/* Reminder Settings */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Erinnerungen</h3>
<div className={styles.field}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={reminderEnabled}
onChange={e => setReminderEnabled(e.target.checked)}
/>
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
Taegliche Coaching-Erinnerung per E-Mail
</label>
</div>
{reminderEnabled && (
<div className={styles.field}>
<label className={styles.label}>Uhrzeit</label>
<input
type="time"
className={styles.input}
value={reminderTime}
onChange={e => setReminderTime(e.target.value)}
/>
<input type="time" className={styles.input} value={reminderTime} onChange={e => setReminderTime(e.target.value)} />
</div>
)}
<div className={styles.field}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={emailEnabled}
onChange={e => setEmailEnabled(e.target.checked)}
/>
<input type="checkbox" checked={emailEnabled} onChange={e => setEmailEnabled(e.target.checked)} />
Session-Zusammenfassung per E-Mail senden
</label>
</div>
</div>
{/* Stats */}
{profile && (
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Statistik</h3>
<div className={styles.statsGrid}>
<div className={styles.statItem}>
<span className={styles.statValue}>{profile.totalSessions}</span>
<span className={styles.statLabel}>Sessions gesamt</span>
</div>
<div className={styles.statItem}>
<span className={styles.statValue}>{profile.totalMinutes}</span>
<span className={styles.statLabel}>Minuten gesamt</span>
</div>
<div className={styles.statItem}>
<span className={styles.statValue}>{profile.streakDays}</span>
<span className={styles.statLabel}>Aktueller Streak</span>
</div>
<div className={styles.statItem}>
<span className={styles.statValue}>{profile.longestStreak}</span>
<span className={styles.statLabel}>Laengster Streak</span>
</div>
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalSessions}</span><span className={styles.statLabel}>Sessions gesamt</span></div>
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalMinutes}</span><span className={styles.statLabel}>Minuten gesamt</span></div>
<div className={styles.statItem}><span className={styles.statValue}>{profile.streakDays}</span><span className={styles.statLabel}>Aktueller Streak</span></div>
<div className={styles.statItem}><span className={styles.statValue}>{profile.longestStreak}</span><span className={styles.statLabel}>Laengster Streak</span></div>
</div>
</div>
)}

View file

@ -6,10 +6,12 @@
*
* 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/voice/preferences).
*/
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import api from '../../../api';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
@ -22,6 +24,7 @@ export interface VoiceControllerApi {
ttsPlaying: () => void;
ttsPaused: () => void;
ttsEnded: () => void;
ttsStopped: () => void;
toggleMute: () => void;
}
@ -30,6 +33,8 @@ export interface VoiceControllerCallbacks {
onInterimText?: (text: string) => void;
}
const _DEFAULT_STT_LANGUAGE = 'de-DE';
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false);
@ -38,6 +43,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
const sttLanguageRef = useRef<string>(_DEFAULT_STT_LANGUAGE);
useEffect(() => {
let cancelled = false;
api.get('/api/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 +85,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 +115,27 @@ 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 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;
@ -110,13 +143,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,
@ -127,6 +160,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
ttsPlaying,
ttsPaused,
ttsEnded,
ttsStopped,
toggleMute,
};
}

Some files were not shown because too many files have changed in this diff Show more