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

View file

@ -281,8 +281,8 @@ export async function fetchWorkflowRuns(
export interface CompletedRun extends Automation2Run { export interface CompletedRun extends Automation2Run {
workflowLabel?: string; workflowLabel?: string;
_modifiedAt?: number; sysModifiedAt?: number;
_createdAt?: number; sysCreatedAt?: number;
} }
export async function fetchCompletedRuns( export async function fetchCompletedRuns(
@ -313,7 +313,7 @@ export interface Automation2Task {
result?: Record<string, unknown>; result?: Record<string, unknown>;
/** Workflow label (enriched by API) */ /** Workflow label (enriched by API) */
workflowLabel?: string; workflowLabel?: string;
/** Unix timestamp ms (from _createdAt) */ /** Unix timestamp ms (from sysCreatedAt) */
createdAt?: number; createdAt?: number;
/** Optional due date - configurable in future */ /** Optional due date - configurable in future */
dueAt?: number; dueAt?: number;

View file

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

View file

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

View file

@ -50,18 +50,6 @@ export interface CoachingPersona {
isActive: boolean; 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 { export interface CoachingBadge {
id: string; id: string;
userId: string; userId: string;
@ -110,8 +98,6 @@ export interface CoachingScore {
export interface CoachingUserProfile { export interface CoachingUserProfile {
id: string; id: string;
userId: string; userId: string;
preferredLanguage: string;
preferredVoice?: string;
dailyReminderTime?: string; dailyReminderTime?: string;
dailyReminderEnabled: boolean; dailyReminderEnabled: boolean;
emailSummaryEnabled: boolean; emailSummaryEnabled: boolean;
@ -299,6 +285,13 @@ export async function cancelSessionApi(request: ApiRequestFunction, instanceId:
// Streaming Chat API // Streaming Chat API
// ============================================================================ // ============================================================================
export interface SendMessageOptions {
fileIds?: string[];
dataSourceIds?: string[];
featureDataSourceIds?: string[];
allowedProviders?: string[];
}
export async function sendMessageStreamApi( export async function sendMessageStreamApi(
instanceId: string, instanceId: string,
sessionId: string, sessionId: string,
@ -307,6 +300,7 @@ export async function sendMessageStreamApi(
onError?: (error: Error) => void, onError?: (error: Error) => void,
onComplete?: () => void, onComplete?: () => void,
signal?: AbortSignal, signal?: AbortSignal,
options?: SendMessageOptions,
): Promise<void> { ): Promise<void> {
try { try {
const baseURL = api.defaults.baseURL || ''; const baseURL = api.defaults.baseURL || '';
@ -318,10 +312,16 @@ export async function sendMessageStreamApi(
if (!getCSRFToken()) generateAndStoreCSRFToken(); if (!getCSRFToken()) generateAndStoreCSRFToken();
addCSRFTokenToHeaders(headers); 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, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ content }), body: JSON.stringify(body),
credentials: 'include', credentials: 'include',
signal, signal,
}); });
@ -494,27 +494,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
return data.profile; 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) // 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' }); 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) // 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} * Endpoint: DELETE /api/mandates/{mandateId}
*/ */
export async function deleteMandate( export async function deleteMandate(
@ -134,3 +134,22 @@ export async function deleteMandate(
method: 'delete' 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; mandateId: string;
content: string; content: string;
name: string; name: string;
_createdBy?: string; sysCreatedBy?: string;
_hideDelete?: boolean; _hideDelete?: boolean;
[key: string]: any; // Allow additional properties [key: string]: any; // Allow additional properties
} }

View file

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

View file

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

View file

@ -19,6 +19,8 @@ export interface SubscriptionPlan {
autoRenew: boolean; autoRenew: boolean;
maxUsers: number | null; maxUsers: number | null;
maxFeatureInstances: number | null; maxFeatureInstances: number | null;
maxDataVolumeMB?: number | null;
budgetAiCHF?: number;
trialDays: number | null; trialDays: number | null;
successorPlanKey: string | null; successorPlanKey: string | null;
} }

View file

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

View file

@ -35,6 +35,7 @@ import {
} from '../nodes/runtime/workflowStartSync'; } from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry'; import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
const LOG = '[Automation2]'; const LOG = '[Automation2]';
@ -55,6 +56,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
initialWorkflowId, initialWorkflowId,
}) => { }) => {
const { request } = useApiRequest(); const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]); const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]); const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -140,8 +142,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations }); await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse); setExecuteResult({ success: true } as ExecuteGraphResponse);
} else { } else {
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow'; const label = await promptInput('Workflow-Name:', {
const created = await createWorkflow(request, instanceId, { label, graph, invocations }); 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); setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations); if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]); setWorkflows((prev) => [...prev, created]);
@ -152,7 +166,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]);
const handleLoad = useCallback( const handleLoad = useCallback(
async (workflowId: string) => { async (workflowId: string) => {
@ -436,7 +450,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
)} )}
</div> </div>
</div> </div>
<PromptDialog />
<WorkflowConfigurationModal <WorkflowConfigurationModal
open={workflowSettingsOpen} open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)} 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 }, { 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[] = [ const standardTrigger: TriggerFormFieldRow[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' }, { name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' }, { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
...(statusTriggerRow ? [statusTriggerRow] : []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number' }, { name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number' },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' }, { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' }, { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
@ -252,8 +263,9 @@ export function buildSyncFromClickUpList(args: {
if (inf) customInput.push(inf); if (inf) customInput.push(inf);
if (tr) customTrigger.push(tr); if (tr) customTrigger.push(tr);
const fid = String((f as ClickUpFieldLike).id ?? ''); const fid = String((f as ClickUpFieldLike).id ?? '');
if (fid && inf?.name) { const payloadKey = inf?.name;
customRefs[fid] = createRef(formNodeId, ['payload', 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 { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
import { Popup } from '../UiComponents/Popup'; import { Popup } from '../UiComponents/Popup';
import { ActionsPanel } from '../ActionsPanel'; 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 { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { useWorkflowActions } from '../../hooks/useAutomations'; import { useWorkflowActions } from '../../hooks/useAutomations';
@ -374,7 +376,8 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [schedule, setSchedule] = useState('0 22 * * *'); const [schedule, setSchedule] = useState('0 22 * * *');
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const [allowedProviders, setAllowedProviders] = useState<string[]>([]); const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders: billingProviders } = useBilling();
// Template multilingual fields // Template multilingual fields
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' }); const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
@ -537,7 +540,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
setLabel(def.label || ''); setLabel(def.label || '');
setSchedule(def.schedule || '0 22 * * *'); setSchedule(def.schedule || '0 22 * * *');
setActive(def.active ?? false); setActive(def.active ?? false);
setAllowedProviders(def.allowedProviders || []); setProviderSelection(_migrateFromLegacy(def.allowedProviders || []));
} }
// Extract template JSON // Extract template JSON
@ -693,7 +696,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
active, active,
template: templateJson, template: templateJson,
placeholders, placeholders,
allowedProviders allowedProviders: _toBackendProviders(providerSelection, billingProviders),
}; };
} }
@ -709,7 +712,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
} finally { } finally {
setIsSaving(false); 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 // Computed values
const editorTitle = title || (mode === 'template' const editorTitle = title || (mode === 'template'
@ -864,12 +867,12 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
{/* Allowed AI Providers */} {/* Allowed AI Providers */}
<div className={styles.formGroup}> <div className={styles.formGroup}>
<ProviderMultiSelect <ProviderMultiSelect
selectedProviders={allowedProviders} selection={providerSelection}
onChange={setAllowedProviders} onChange={setProviderSelection}
label="Erlaubte AI-Provider" label="Erlaubte AI-Provider"
/> />
<p className={styles.formHint}> <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> </p>
</div> </div>
</div> </div>

View file

@ -25,7 +25,7 @@
.treeNode.multiSelected { .treeNode.multiSelected {
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14)); 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 { .treeNode.multiSelected:hover {
@ -34,7 +34,7 @@
.treeNode.dropTarget { .treeNode.dropTarget {
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15)); 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; outline-offset: -2px;
} }
@ -77,7 +77,7 @@
.renameInput { .renameInput {
flex: 1; flex: 1;
border: 1px solid var(--color-primary, #1976d2); border: 1px solid var(--color-primary, #F25843);
border-radius: 3px; border-radius: 3px;
padding: 1px 4px; padding: 1px 4px;
font-size: inherit; font-size: inherit;
@ -146,7 +146,25 @@
font-size: 10px; font-size: 10px;
color: var(--color-text-secondary, #999); color: var(--color-text-secondary, #999);
flex-shrink: 0; 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; margin-left: auto;
flex-shrink: 0;
}
.rightZone .actions {
margin-left: 0;
} }
.rootActions { .rootActions {

View file

@ -13,6 +13,7 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa'; 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'; import styles from './FolderTree.module.css';
/* ── Public types ──────────────────────────────────────────────────────── */ /* ── Public types ──────────────────────────────────────────────────────── */
@ -30,6 +31,8 @@ export interface FileNode {
mimeType?: string; mimeType?: string;
fileSize?: number; fileSize?: number;
folderId?: string | null; folderId?: string | null;
scope?: string;
neutralize?: boolean;
} }
export interface TreeItem { export interface TreeItem {
@ -62,6 +65,8 @@ export interface FolderTreeProps {
onDeleteFiles?: (fileIds: string[]) => Promise<void>; onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>; onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>; onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
} }
/* ── Helpers ───────────────────────────────────────────────────────────── */ /* ── Helpers ───────────────────────────────────────────────────────────── */
@ -146,6 +151,22 @@ function _fileIcon(mime?: string): string {
/* ── Selection context threaded through the tree ──────────────────────── */ /* ── 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 { interface SelectionCtx {
selectedItemIds: Set<string>; selectedItemIds: Set<string>;
selectedFileIds: string[]; selectedFileIds: string[];
@ -156,6 +177,8 @@ interface SelectionCtx {
onDeleteFile?: (fileId: string) => Promise<void>; onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>; onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>; onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
} }
/* ── File node (leaf) ─────────────────────────────────────────────────── */ /* ── File node (leaf) ─────────────────────────────────────────────────── */
@ -227,39 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
) : ( ) : (
<span className={styles.folderName}>{file.fileName}</span> <span className={styles.folderName}>{file.fileName}</span>
)} )}
{!renaming && file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
{!renaming && ( {!renaming && (
<span className={styles.actions}> <span className={styles.rightZone}>
{sel.onRenameFile && !multiSelected && ( <span className={styles.actions}>
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen"> {sel.onRenameFile && !multiSelected && (
<FaPen /> <button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
</button> <FaPen />
)}
{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> </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> </span>
)} )}
@ -277,6 +331,7 @@ interface TreeNodeProps {
showFiles: boolean; showFiles: boolean;
filesByFolder: Map<string, FileNode[]>; filesByFolder: Map<string, FileNode[]>;
sel: SelectionCtx; sel: SelectionCtx;
promptFolderName: (message: string, options?: PromptOptions) => Promise<string | null>;
onToggle: (id: string) => void; onToggle: (id: string) => void;
onSelect: (id: string | null) => void; onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>; onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
@ -291,6 +346,7 @@ interface TreeNodeProps {
function _TreeNode({ function _TreeNode({
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
promptFolderName,
onToggle, onSelect, onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder, onDownloadFolder,
@ -321,12 +377,12 @@ function _TreeNode({
const _handleAdd = useCallback(async (e: React.MouseEvent) => { const _handleAdd = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!onCreateFolder) return; if (!onCreateFolder) return;
const name = prompt('Neuer Ordnername:'); const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
if (name?.trim()) { if (name?.trim()) {
await onCreateFolder(name.trim(), node.id); await onCreateFolder(name.trim(), node.id);
if (!expandedIds.has(node.id)) onToggle(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) => { const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -488,6 +544,7 @@ function _TreeNode({
showFiles={showFiles} showFiles={showFiles}
filesByFolder={filesByFolder} filesByFolder={filesByFolder}
sel={sel} sel={sel}
promptFolderName={promptFolderName}
onToggle={onToggle} onToggle={onToggle}
onSelect={onSelect} onSelect={onSelect}
onCreateFolder={onCreateFolder} onCreateFolder={onCreateFolder}
@ -517,11 +574,13 @@ export default function FolderTree({
expandedIds: externalExpandedIds, onToggleExpand, expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle,
}: FolderTreeProps) { }: FolderTreeProps) {
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set()); const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [rootDropOver, setRootDropOver] = useState(false); const [rootDropOver, setRootDropOver] = useState(false);
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set()); const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const lastClickedIdRef = useRef<string | null>(null); const lastClickedIdRef = useRef<string | null>(null);
const { prompt: promptFolderName, PromptDialog } = usePrompt();
const expandedIds = externalExpandedIds ?? internalExpandedIds; const expandedIds = externalExpandedIds ?? internalExpandedIds;
@ -634,8 +693,10 @@ export default function FolderTree({
onDeleteFile, onDeleteFile,
onDeleteFiles, onDeleteFiles,
onDeleteFolders, 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) => { const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -699,7 +760,7 @@ export default function FolderTree({
className={styles.actionBtn} className={styles.actionBtn}
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); 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); if (name?.trim()) await onCreateFolder(name.trim(), null);
}} }}
title="Neuer Ordner" title="Neuer Ordner"
@ -720,6 +781,7 @@ export default function FolderTree({
showFiles={showFiles} showFiles={showFiles}
filesByFolder={filesByFolder} filesByFolder={filesByFolder}
sel={sel} sel={sel}
promptFolderName={promptFolderName}
onToggle={_handleToggle} onToggle={_handleToggle}
onSelect={onSelect} onSelect={onSelect}
onCreateFolder={onCreateFolder} onCreateFolder={onCreateFolder}
@ -736,6 +798,7 @@ export default function FolderTree({
<_FileItem key={file.id} file={file} sel={sel} /> <_FileItem key={file.id} file={file} sel={sel} />
))} ))}
</div> </div>
<PromptDialog />
</div> </div>
); );
} }

View file

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

View file

@ -282,6 +282,27 @@
margin-top: 0.5rem; 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 */ /* Dark Theme */
:global(.dark-theme) .separator { :global(.dark-theme) .separator {
background: var(--border-dark, #333); background: var(--border-dark, #333);

View file

@ -20,7 +20,7 @@
* - Users, Mandates, Roles, ... * - Users, Mandates, Roles, ...
*/ */
import React, { useMemo } from 'react'; import React, { useMemo, useCallback } from 'react';
import { useNavigation } from '../../hooks/useNavigation'; import { useNavigation } from '../../hooks/useNavigation';
import type { import type {
DynamicBlock, DynamicBlock,
@ -31,8 +31,11 @@ import type {
FeatureView FeatureView
} from '../../hooks/useNavigation'; } from '../../hooks/useNavigation';
import { getPageIcon } from '../../config/pageRegistry'; 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 { 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'; import styles from './MandateNavigation.module.css';
// ============================================================================= // =============================================================================
@ -84,16 +87,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
* Convert a FeatureInstance to TreeNodeItem (with feature icon) * Convert a FeatureInstance to TreeNodeItem (with feature icon)
* Instance node gets path to first view so clicking the instance name navigates to dashboard. * 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. * 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 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 { return {
id: instance.id, id: instance.id,
label: instance.uiLabel, label: instance.uiLabel,
icon: getPageIcon(featureUiComponent), // Use feature icon for instance icon: getPageIcon(featureUiComponent),
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
children, children,
defaultExpanded: false, defaultExpanded: false,
actions: renameAction,
}; };
} }
@ -106,16 +125,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent
* Before: Mandate Feature Instance Views * Before: Mandate Feature Instance Views
* Now: Mandate Instance (with feature icon) 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) { if (mandate.features.length === 0) {
return null; return null;
} }
// Flatten: collect all instances from all features directly under mandate
const instanceNodes: TreeNodeItem[] = []; const instanceNodes: TreeNodeItem[] = [];
for (const feature of mandate.features) { for (const feature of mandate.features) {
for (const instance of feature.instances) { 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) * 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 return block.mandates
.map(navigationMandateToTreeNode) .map((m) => navigationMandateToTreeNode(m, onRename))
.filter((node): node is TreeNodeItem => node !== null); .filter((node): node is TreeNodeItem => node !== null);
} }
@ -169,18 +193,24 @@ const EmptyState: React.FC = () => (
// ============================================================================= // =============================================================================
export const MandateNavigation: React.FC = () => { export const MandateNavigation: React.FC = () => {
// Fetch navigation from new API (blocks structure, already filtered by permissions) const { blocks, loading, refresh } = useNavigation('de');
const { blocks, loading } = useNavigation('de'); const { prompt, PromptDialog } = usePrompt();
const { showWarning } = useToast();
// Build navigation items from blocks
// Groups static items into collapsible containers: const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.) const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
// - "Administration": admin items, possibly with subgroups if (!newLabel || newLabel.trim() === currentLabel) return;
// - Dynamic block (mandates) renders between them 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 navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = []; const items: TreeItem[] = [];
// Collect static items by category
const meineSichtItems: NavigationItem[] = []; const meineSichtItems: NavigationItem[] = [];
let adminItems: NavigationItem[] = []; let adminItems: NavigationItem[] = [];
let adminSubgroups: NavSubgroup[] = []; let adminSubgroups: NavSubgroup[] = [];
@ -199,15 +229,13 @@ export const MandateNavigation: React.FC = () => {
} }
} }
// "Meine Sicht" - collapsible container for user-facing pages
if (meineSichtItems.length > 0) { if (meineSichtItems.length > 0) {
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true)); items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
} }
// Dynamic block: mandates with feature instances
for (const block of blocks) { for (const block of blocks) {
if (block.type === 'dynamic') { if (block.type === 'dynamic') {
const mandateNodes = dynamicBlockToTreeNodes(block); const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename);
if (mandateNodes.length > 0) { if (mandateNodes.length > 0) {
if (items.length > 0) items.push({ type: 'separator' }); if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes); 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 (adminSubgroups.length > 0) {
if (items.length > 0) items.push({ type: 'separator' }); if (items.length > 0) items.push({ type: 'separator' });
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({ const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
@ -236,7 +263,7 @@ export const MandateNavigation: React.FC = () => {
} }
return items; return items;
}, [blocks]); }, [blocks, _handleRename]);
// Check if user has any navigation (static or dynamic) // Check if user has any navigation (static or dynamic)
const hasNavigation = blocks.length > 0; const hasNavigation = blocks.length > 0;
@ -260,6 +287,7 @@ export const MandateNavigation: React.FC = () => {
) : ( ) : (
<EmptyState /> <EmptyState />
)} )}
<PromptDialog />
</div> </div>
); );
}; };

View file

@ -257,6 +257,22 @@
color: white; 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 */ /* DARK THEME */
/* ============================================ */ /* ============================================ */

View file

@ -47,6 +47,8 @@ export interface TreeNodeItem {
level?: number; level?: number;
/** Data attribute for testing/identification */ /** Data attribute for testing/identification */
dataId?: string; dataId?: string;
/** Inline action element rendered at the end of the row (e.g. rename icon) */
actions?: ReactNode;
} }
export interface TreeSectionItem { export interface TreeSectionItem {
@ -219,6 +221,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
{node.badge} {node.badge}
</span> </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 { useNavigate } from 'react-router-dom';
import { useCurrentUser } from '../../hooks/useUsers'; import { useCurrentUser } from '../../hooks/useUsers';
import { NotificationBell } from '../NotificationBell'; import { NotificationBell } from '../NotificationBell';
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
import styles from './UserSection.module.css'; import styles from './UserSection.module.css';
export const UserSection: React.FC = () => { export const UserSection: React.FC = () => {
@ -16,6 +17,7 @@ export const UserSection: React.FC = () => {
const [isLoggingOut, setIsLoggingOut] = useState(false); const [isLoggingOut, setIsLoggingOut] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [showLegalModal, setShowLegalModal] = useState(false); const [showLegalModal, setShowLegalModal] = useState(false);
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
const handleLogout = async () => { const handleLogout = async () => {
setIsLoggingOut(true); setIsLoggingOut(true);
@ -41,6 +43,13 @@ export const UserSection: React.FC = () => {
setShowLegalModal(true); setShowLegalModal(true);
setShowMenu(false); setShowMenu(false);
}; };
const handleOnboarding = () => {
_showOnboarding();
setOnboardingHidden(false);
navigate('/', { state: { showOnboarding: Date.now() } });
setShowMenu(false);
};
if (!user) { if (!user) {
return null; return null;
@ -61,7 +70,7 @@ export const UserSection: React.FC = () => {
<button <button
className={styles.userButton} className={styles.userButton}
onClick={() => setShowMenu(!showMenu)} onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
aria-expanded={showMenu} aria-expanded={showMenu}
> >
<div className={styles.avatar}> <div className={styles.avatar}>
@ -94,6 +103,16 @@ export const UserSection: React.FC = () => {
Einstellungen Einstellungen
</button> </button>
{onboardingHidden && (
<button
className={styles.menuItem}
onClick={handleOnboarding}
>
<span className={styles.menuIcon}>{'\uD83E\uDDED'}</span>
Onboarding-Assistent
</button>
)}
<button <button
className={styles.menuItem} className={styles.menuItem}
onClick={handleLegal} onClick={handleLegal}

View file

@ -18,8 +18,11 @@ const typeIcons: Record<string, React.ReactNode> = {
mention: <FaExclamationTriangle /> mention: <FaExclamationTriangle />
}; };
// Format timestamp to relative time // Format timestamp to relative time (Unix seconds)
function formatRelativeTime(timestamp: number): string { function formatRelativeTime(timestamp: number): string {
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return '';
}
const now = Date.now() / 1000; const now = Date.now() / 1000;
const diff = now - timestamp; const diff = now - timestamp;
@ -29,6 +32,9 @@ function formatRelativeTime(timestamp: number): string {
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`; if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleDateString('de-DE'); 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; justify-content: center;
width: 36px; width: 36px;
height: 36px; height: 36px;
border: 1px solid var(--border-color, #3a3a3a); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px; border-radius: 6px;
background: var(--surface-color, #2d2d2d); background: var(--surface-color, #ffffff);
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.triggerButton:hover:not(:disabled) { .triggerButton:hover:not(:disabled) {
background: var(--bg-secondary, #3a3a3a); background: var(--hover-bg, rgba(0, 0, 0, 0.06));
color: var(--text-primary, #fff); color: var(--text-primary, #1a1a1a);
} }
.triggerButton:disabled { .triggerButton:disabled {
@ -83,20 +83,20 @@
transform: translateX(-50%); transform: translateX(-50%);
z-index: 1000; z-index: 1000;
padding: 8px; padding: 8px;
background: var(--surface-color, #2d2d2d); background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #3a3a3a); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px; 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; min-width: 220px;
} }
.dropdownHeader { .dropdownHeader {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
padding: 4px 8px; padding: 4px 8px;
margin-bottom: 4px; margin-bottom: 4px;
border-bottom: 1px solid var(--border-color, #3a3a3a); border-bottom: 1px solid var(--border-color, #e0e0e0);
} }
.selectActions { .selectActions {
@ -108,18 +108,18 @@
.actionButton { .actionButton {
flex: 1; flex: 1;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid var(--border-color, #3a3a3a); border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px; border-radius: 4px;
background: var(--bg-secondary, #252525); background: var(--bg-secondary, #f8f9fa);
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
font-size: 0.75rem; font-size: 0.75rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.actionButton:hover:not(:disabled) { .actionButton:hover:not(:disabled) {
background: var(--bg-hover, #3a3a3a); background: var(--hover-bg, rgba(0, 0, 0, 0.06));
color: var(--text-primary, #fff); color: var(--text-primary, #1a1a1a);
} }
.actionButton.active { .actionButton.active {
@ -138,7 +138,7 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
padding: 4px; padding: 4px;
background: var(--bg-secondary, #252525); background: var(--bg-secondary, #f8f9fa);
border-radius: 4px; border-radius: 4px;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
@ -151,12 +151,13 @@
padding: 6px 8px; padding: 6px 8px;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease; transition: background 0.15s ease, color 0.15s ease;
color: var(--text-primary, #e0e0e0); color: var(--text-primary, #1a1a1a);
} }
.checkboxItem:hover { .checkboxItem:hover {
background: var(--bg-hover, #3a3a3a); background: var(--hover-bg, rgba(0, 0, 0, 0.06));
color: var(--text-primary, #1a1a1a);
} }
.checkboxItem.disabled { .checkboxItem.disabled {
@ -177,12 +178,12 @@
.providerName { .providerName {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-primary, #e0e0e0); color: inherit;
} }
.hint { .hint {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-tertiary, #666); color: var(--text-tertiary, #888888);
text-align: center; text-align: center;
padding: 4px 0; padding: 4px 0;
} }
@ -192,10 +193,24 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 12px; padding: 12px;
color: var(--text-secondary, #888); color: var(--text-secondary, #666666);
font-size: 0.8rem; 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 PROVIDER BADGES
============================================================================ */ ============================================================================ */

View file

@ -1,19 +1,80 @@
/** /**
* ProviderSelector Component * ProviderSelector Component
* *
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern. * Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
* Kann im AI Workspace und Automation Editor verwendet werden. * Kann im AI Workspace und Automation Editor verwendet werden.
* *
* Features: * Selektionsmodell:
* - Dropdown für Einzelauswahl * ProviderSelection { include: string[], exclude: string[] }
* - Checkbox-Liste für Mehrfachauswahl * - include(["ALL"]), exclude([]) alle verfügbaren Provider (dynamisch)
* - Lädt verfügbare Provider aus dem Billing-System * - 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 React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { useBilling } from '../../hooks/useBilling'; import { useBilling } from '../../hooks/useBilling';
import styles from './ProviderSelector.module.css'; 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 // Provider display names
const PROVIDER_LABELS: Record<string, string> = { const PROVIDER_LABELS: Record<string, string> = {
anthropic: 'Anthropic (Claude)', anthropic: 'Anthropic (Claude)',
@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record<string, string> = {
internal: 'Internal', internal: 'Internal',
}; };
// Provider icons (emojis for simplicity)
const PROVIDER_ICONS: Record<string, string> = { const PROVIDER_ICONS: Record<string, string> = {
anthropic: '🤖', anthropic: '🤖',
openai: '💬', openai: '💬',
@ -58,20 +118,20 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
showLabel = true, showLabel = true,
}) => { }) => {
const { allowedProviders, loadAllowedProviders, loading } = useBilling(); const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => { useEffect(() => {
if (allowedProviders.length === 0 && !loading) { if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders(); loadAllowedProviders();
} }
}, []); }, []);
const providerOptions = useMemo(() => { const providerOptions = useMemo(() => {
return allowedProviders.map((provider) => ({ return allowedProviders.map((provider) => ({
value: provider, value: provider,
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`, label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
})); }));
}, [allowedProviders]); }, [allowedProviders]);
return ( return (
<div className={`${styles.providerSelect} ${className || ''}`}> <div className={`${styles.providerSelect} ${className || ''}`}>
{showLabel && <label className={styles.label}>{label}</label>} {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 { interface ProviderMultiSelectProps {
selectedProviders: string[]; selection: ProviderSelection;
onChange: (providers: string[]) => void; onChange: (selection: ProviderSelection) => void;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
label?: string; label?: string;
@ -108,7 +168,7 @@ interface ProviderMultiSelectProps {
} }
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
selectedProviders, selection,
onChange, onChange,
disabled = false, disabled = false,
className, className,
@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { allowedProviders, loadAllowedProviders, loading } = useBilling(); const { allowedProviders, loadAllowedProviders, loading } = useBilling();
useEffect(() => { useEffect(() => {
if (allowedProviders.length === 0 && !loading) { if (allowedProviders.length === 0 && !loading) {
loadAllowedProviders(); loadAllowedProviders();
} }
}, []); }, []);
// Apply default exclusions when providers first load // Apply default exclusions once when providers first load
useEffect(() => { useEffect(() => {
if ( if (
!initialExcludeApplied && !initialExcludeApplied &&
allowedProviders.length > 0 && allowedProviders.length > 0 &&
excludeByDefault.length > 0 && excludeByDefault.length > 0 &&
selectedProviders.length === 0 _isAllSelected(selection)
) { ) {
const initialSelection = allowedProviders.filter( onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] });
(p) => !excludeByDefault.includes(p)
);
// Only apply if there's actually something to exclude
if (initialSelection.length < allowedProviders.length) {
onChange(initialSelection);
}
setInitialExcludeApplied(true); setInitialExcludeApplied(true);
} }
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]); }, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
// Click outside handler const _handleClickOutside = useCallback((event: MouseEvent) => {
const handleClickOutside = useCallback((event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false); setIsExpanded(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isExpanded) { if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', _handleClickOutside);
} }
}, [isExpanded, handleClickOutside]); }, [isExpanded, _handleClickOutside]);
// Effective selection: empty array = all providers active (no restriction) const effectiveSelection = useMemo(
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; () => _resolveProviders(selection, allowedProviders),
[selection, allowedProviders],
// "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 allSelected = _isAllSelected(selection);
const noneSelected = effectiveSelection.length === 0;
const handleToggle = (provider: string) => {
if (selectedProviders.length === 0) { const _handleToggle = (provider: string) => {
// Currently "all active" (no restriction) -> make explicit: all except the toggled one const isChecked = effectiveSelection.includes(provider);
onChange(allowedProviders.filter((p) => p !== provider));
} else if (selectedProviders.includes(provider)) { if (selection.include.includes(PROVIDER_ALL)) {
// Deactivate: remove from selection // Currently ALL-based: toggle modifies exclude list
const remaining = selectedProviders.filter((p) => p !== provider); if (isChecked) {
// If removing leaves all others selected, reset to [] (= all, no restriction) onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] });
if (remaining.length === allowedProviders.length) {
onChange([]);
} else { } else {
onChange(remaining); const nextExclude = selection.exclude.filter((p) => p !== provider);
onChange({ include: [PROVIDER_ALL], exclude: nextExclude });
} }
} else { } else {
// Activate: add to selection // Explicit include list
const updated = [...selectedProviders, provider]; if (isChecked) {
// If all are now selected, reset to [] (= all, no restriction) onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] });
if (updated.length === allowedProviders.length) {
onChange([]);
} else { } 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 = () => { const _handleSelectAll = () => {
onChange([]); // Empty = all active, no restriction onChange({ include: [PROVIDER_ALL], exclude: [] });
}; };
// Summary icon for button
const summaryIcon = useMemo(() => { const summaryIcon = useMemo(() => {
if (noneSelected) return '⊘';
if (effectiveSelection.length === 1) { if (effectiveSelection.length === 1) {
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌'; return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
} }
return '🤖'; return '⚡';
}, [effectiveSelection]); }, [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 ( return (
<div <div
ref={containerRef} ref={containerRef}
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`} className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
> >
{/* Trigger Button - styled like iconButton */} <button
<button
type="button" type="button"
className={styles.triggerButton} className={styles.triggerButton}
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
@ -220,36 +283,35 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
> >
<span className={styles.buttonIcon}>{summaryIcon}</span> <span className={styles.buttonIcon}>{summaryIcon}</span>
</button> </button>
{/* Dropdown Content */}
{isExpanded && ( {isExpanded && (
<div className={styles.dropdownContent}> <div className={styles.dropdownContent}>
{showLabel && <div className={styles.dropdownHeader}>{label}</div>} {showLabel && <div className={styles.dropdownHeader}>{label}</div>}
<div className={styles.selectActions}> <div className={styles.selectActions}>
<button <button
type="button" type="button"
onClick={handleSelectAll} onClick={_handleSelectAll}
disabled={disabled} disabled={disabled}
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`} className={`${styles.actionButton} ${allSelected ? styles.active : ''}`}
> >
Alle Alle
</button> </button>
</div> </div>
{loading ? ( {loading ? (
<div className={styles.loading}>Lade...</div> <div className={styles.loading}>Lade...</div>
) : ( ) : (
<div className={styles.checkboxList}> <div className={styles.checkboxList}>
{allowedProviders.map((provider) => ( {allowedProviders.map((provider) => (
<label <label
key={provider} key={provider}
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`} className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
> >
<input <input
type="checkbox" type="checkbox"
checked={effectiveSelection.includes(provider)} checked={effectiveSelection.includes(provider)}
onChange={() => handleToggle(provider)} onChange={() => _handleToggle(provider)}
disabled={disabled} disabled={disabled}
/> />
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span> <span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
@ -260,12 +322,8 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
))} ))}
</div> </div>
)} )}
{isAllSelected && !loading && ( <div className={styles.hint}>{summaryHint}</div>
<div className={styles.hint}>
Alle Provider aktiv (kein Filter)
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -288,7 +346,7 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
if (providers.length === 0) { if (providers.length === 0) {
return <span className={styles.allProviders}>Alle Provider</span>; return <span className={styles.allProviders}>Alle Provider</span>;
} }
return ( return (
<div className={`${styles.providerBadges} ${className || ''}`}> <div className={`${styles.providerBadges} ${className || ''}`}>
{providers.map((provider) => ( {providers.map((provider) => (
@ -300,5 +358,4 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
); );
}; };
// Default export
export default ProviderSelect; export default ProviderSelect;

View file

@ -5,6 +5,19 @@
export { export {
ProviderSelect, ProviderSelect,
ProviderMultiSelect, ProviderMultiSelect,
ProviderBadges ProviderBadges,
} from './ProviderSelector'; } from './ProviderSelector';
export {
PROVIDER_ALL,
_defaultProviderSelection,
_resolveProviders,
_isAllSelected,
_isNoneSelected,
_migrateFromLegacy,
_toBackendProviders,
} from './ProviderSelector';
export type { ProviderSelection } from './ProviderSelector';
export { default } from './ProviderSelector'; export { default } from './ProviderSelector';

View file

@ -10,6 +10,7 @@ import {
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa'; import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
import { WorkflowFile } from '../../../hooks/usePlayground'; import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css'; import styles from './ConnectedFilesList.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
export interface ConnectedFilesListActionButton { export interface ConnectedFilesListActionButton {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove'; type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
@ -240,7 +241,7 @@ export function ConnectedFilesList({
</div> </div>
<div className={styles.fileMeta}> <div className={styles.fileMeta}>
<span className={styles.fileSize}> <span className={styles.fileSize}>
{formatFileSize(file.fileSize)} {formatBinaryDataSizeBytes(file.fileSize)}
</span> </span>
{file.source && ( {file.source && (
<span className={styles.fileSource}> <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; export default ConnectedFilesList;

View file

@ -2,6 +2,8 @@
* Utility functions for message formatting and styling * Utility functions for message formatting and styling
*/ */
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
/** /**
* Formats a timestamp to a readable date/time string * Formats a timestamp to a readable date/time string
* Handles both Unix timestamps in seconds and milliseconds * Handles both Unix timestamps in seconds and milliseconds
@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => {
} }
}; };
/** /** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */
* Formats file size to human-readable format export const formatFileSize = formatBinaryDataSizeBytes;
*/
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];
};
/** /**
* Gets status badge color class based on status * Gets status badge color class based on status

View file

@ -15,6 +15,12 @@ export interface MessageDocument {
taskNumber: number; taskNumber: number;
actionNumber: number; actionNumber: number;
actionId: string; 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 return false; // Don't show readonly fields in edit form
} }
// Also filter out common non-editable fields // Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name); return !nonEditableFields.includes(attr.name);
}) })
.map(attr => { .map(attr => {
@ -305,7 +305,7 @@ export function useMandates() {
return false; return false;
} }
// Filter out ID fields and other auto-generated fields // 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); return !nonEditableFields.includes(attr.name);
}) })
.map(attr => { .map(attr => {

View file

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

View file

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

View file

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

View file

@ -536,7 +536,7 @@ export function useAutomationTemplates() {
return await fetchAutomationTemplateById(request, templateId); return await fetchAutomationTemplateById(request, templateId);
}, [request]); }, [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); return await createAutomationTemplateApi(request, data);
}, [request]); }, [request]);

View file

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

View file

@ -14,6 +14,7 @@ import {
createTaskApi, updateTaskStatusApi, deleteTaskApi, createTaskApi, updateTaskStatusApi, deleteTaskApi,
type CoachingContext, type CoachingSession, type CoachingMessage, type CoachingContext, type CoachingSession, type CoachingMessage,
type CoachingTask, type CoachingScore, type SSEEvent, type CoachingTask, type CoachingScore, type SSEEvent,
type SendMessageOptions,
} from '../api/commcoachApi'; } from '../api/commcoachApi';
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback'; import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
@ -37,12 +38,14 @@ export interface CommcoachHookReturn {
inputValue: string; inputValue: string;
setInputValue: (v: string) => void; setInputValue: (v: string) => void;
agentToolCalls: Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>;
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>; selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>; createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
archiveContext: (contextId: string) => Promise<void>; archiveContext: (contextId: string) => Promise<void>;
startSession: (personaId?: 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>; sendAudio: (audioBlob: Blob) => Promise<void>;
completeSession: () => Promise<void>; completeSession: () => Promise<void>;
cancelSession: () => Promise<void>; cancelSession: () => Promise<void>;
@ -67,9 +70,10 @@ export interface CommcoachHookReturn {
refreshContexts: () => Promise<void>; refreshContexts: () => Promise<void>;
} }
export function useCommcoach(): CommcoachHookReturn { export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
const { request } = useApiRequest(); const { request } = useApiRequest();
const instanceId = useInstanceId(); const routeInstanceId = useInstanceId();
const instanceId = instanceIdOverride || routeInstanceId;
const [contexts, setContexts] = useState<CoachingContext[]>([]); const [contexts, setContexts] = useState<CoachingContext[]>([]);
const [selectedContextId, setSelectedContextId] = useState<string | null>(null); const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [inputValue, setInputValue] = useState(''); 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); const [actionLoading, setActionLoading] = useState<string | null>(null);
@ -239,6 +244,7 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null); setError(null);
setIsStreaming(true); setIsStreaming(true);
setStreamingStatus(null); setStreamingStatus(null);
setStreamingMessage(null);
setMessages([]); setMessages([]);
setSession(null); setSession(null);
try { try {
@ -259,7 +265,7 @@ export function useCommcoach(): CommcoachHookReturn {
setMessages(eventData.messages); setMessages(eventData.messages);
} }
} else if (eventType === 'messageChunk' && eventData) { } else if (eventType === 'messageChunk' && eventData) {
setStreamingMessage(eventData.accumulated || ''); setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
} else if (eventType === 'message' && eventData) { } else if (eventType === 'message' && eventData) {
setStreamingMessage(null); setStreamingMessage(null);
const msg: CoachingMessage = { const msg: CoachingMessage = {
@ -313,7 +319,7 @@ export function useCommcoach(): CommcoachHookReturn {
} }
}, [instanceId, selectedContextId, ttsPlayback.play]); }, [instanceId, selectedContextId, ttsPlayback.play]);
const sendMessage = useCallback(async (content: string) => { const sendMessage = useCallback(async (content: string, options?: SendMessageOptions) => {
const normalizedContent = content.trim(); const normalizedContent = content.trim();
if (!normalizedContent || !instanceId || !session) return; if (!normalizedContent || !instanceId || !session) return;
@ -326,6 +332,8 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null); setError(null);
setIsStreaming(true); setIsStreaming(true);
setStreamingStatus(null); setStreamingStatus(null);
setStreamingMessage(null);
setAgentToolCalls([]);
const tempMsg: CoachingMessage = { const tempMsg: CoachingMessage = {
id: `temp-${Date.now()}`, id: `temp-${Date.now()}`,
@ -350,7 +358,7 @@ export function useCommcoach(): CommcoachHookReturn {
const eventData = event.data; const eventData = event.data;
if (eventType === 'messageChunk' && eventData) { if (eventType === 'messageChunk' && eventData) {
setStreamingMessage(eventData.accumulated || ''); setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
} else if (eventType === 'message' && eventData) { } else if (eventType === 'message' && eventData) {
setStreamingMessage(null); setStreamingMessage(null);
const msg: CoachingMessage = { const msg: CoachingMessage = {
@ -374,6 +382,17 @@ export function useCommcoach(): CommcoachHookReturn {
ttsPlayback.play(eventData.audio); ttsPlayback.play(eventData.audio);
} else if (eventType === 'status' && eventData) { } else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null); 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) { } else if (eventType === 'taskCreated' && eventData) {
setTasks(prev => [eventData, ...prev]); setTasks(prev => [eventData, ...prev]);
} else if (eventType === 'documentCreated' && eventData) { } else if (eventType === 'documentCreated' && eventData) {
@ -400,6 +419,7 @@ export function useCommcoach(): CommcoachHookReturn {
} }
}, },
ac.signal, ac.signal,
options,
); );
} catch (err: any) { } catch (err: any) {
if (err.name === 'AbortError') return; if (err.name === 'AbortError') return;
@ -417,6 +437,7 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null); setError(null);
setIsStreaming(true); setIsStreaming(true);
setStreamingStatus(null); setStreamingStatus(null);
setStreamingMessage(null);
try { try {
await sendAudioStreamApi( await sendAudioStreamApi(
instanceId, instanceId,
@ -427,7 +448,9 @@ export function useCommcoach(): CommcoachHookReturn {
const eventType = event.type; const eventType = event.type;
const eventData = event.data; 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); setStreamingStatus(eventData.label || null);
} else if (eventType === 'message' && eventData) { } else if (eventType === 'message' && eventData) {
if (eventData.role === 'assistant') setError(null); if (eventData.role === 'assistant') setError(null);
@ -555,6 +578,7 @@ export function useCommcoach(): CommcoachHookReturn {
session, messages, isStreaming, streamingStatus, streamingMessage, session, messages, isStreaming, streamingStatus, streamingMessage,
tasks, scores, sessions, tasks, scores, sessions,
error, inputValue, setInputValue, error, inputValue, setInputValue,
agentToolCalls,
selectContext, createContext, archiveContext, selectContext, createContext, archiveContext,
startSession: startSessionCb, startSession: startSessionCb,
sendMessage, sendAudio, sendMessage, sendAudio,

View file

@ -104,7 +104,7 @@ export function useConfirm() {
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500, padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
border: '1px solid var(--color-border, #444)', border: '1px solid var(--color-border, #444)',
background: 'transparent', background: 'transparent',
color: 'var(--text-secondary, #aaa)', color: 'var(--text-primary, #e8e8e8)',
cursor: 'pointer', cursor: 'pointer',
}} }}
> >
@ -116,7 +116,7 @@ export function useConfirm() {
style={{ style={{
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600, padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
border: 'none', border: 'none',
background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)', background: isDanger ? '#ef4444' : 'var(--primary-color, #F25843)',
color: '#fff', color: '#fff',
cursor: 'pointer', cursor: 'pointer',
}} }}

View file

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

View file

@ -49,7 +49,7 @@ export interface Invitation {
export interface InvitationCreate { export interface InvitationCreate {
/** Username of the user to invite (optional when email is provided) */ /** Username of the user to invite (optional when email is provided) */
targetUsername?: string; targetUsername?: string;
/** Email address to send invitation link (required for new users) */ /** Email to send invitation link; optional if targetUsername is set */
email?: string; email?: string;
roleIds: string[]; roleIds: string[];
featureInstanceId?: string; featureInstanceId?: string;

View file

@ -15,6 +15,7 @@ import {
createMandate as createMandateApi, createMandate as createMandateApi,
updateMandate as updateMandateApi, updateMandate as updateMandateApi,
deleteMandate as deleteMandateApi, deleteMandate as deleteMandateApi,
hardDeleteMandate as hardDeleteMandateApi,
type Mandate, type Mandate,
type MandateUpdateData, type MandateUpdateData,
type PaginationParams type PaginationParams
@ -203,6 +204,19 @@ export function useAdminMandates() {
} }
}, [request, fetchMandates]); }, [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 // Inline update
const handleInlineUpdate = useCallback(async ( const handleInlineUpdate = useCallback(async (
mandateId: string, mandateId: string,
@ -231,6 +245,7 @@ export function useAdminMandates() {
handleCreate, handleCreate,
handleUpdate, handleUpdate,
handleDelete, handleDelete,
handleHardDelete,
handleInlineUpdate, handleInlineUpdate,
updateOptimistically, updateOptimistically,
}; };

View file

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

View file

@ -10,6 +10,48 @@ import api from '../api';
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']); 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 // Types
export interface NotificationAction { export interface NotificationAction {
actionId: string; actionId: string;
@ -30,6 +72,7 @@ export interface UserNotification {
actions?: NotificationAction[]; actions?: NotificationAction[];
actionTaken?: string; actionTaken?: string;
actionResult?: string; actionResult?: string;
/** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */
createdAt: number; createdAt: number;
readAt?: number; readAt?: number;
actionedAt?: number; actionedAt?: number;
@ -74,7 +117,7 @@ export function useNotifications() {
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`; const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
const response = await api.get(url); const response = await api.get(url);
const data = response.data as UserNotification[]; const data = _normalizeNotificationList(response.data);
setNotifications(data); setNotifications(data);
return data; return data;
} catch (err: any) { } catch (err: any) {
@ -101,9 +144,9 @@ export function useNotifications() {
const listRes = await api.get('/api/notifications', { const listRes = await api.get('/api/notifications', {
params: { status: 'unread', limit: 25 }, params: { status: 'unread', limit: 25 },
}); });
const list = listRes.data as UserNotification[]; const list = _normalizeNotificationList(listRes.data);
if ( if (
Array.isArray(list) && list.length > 0 &&
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType)) list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
) { ) {
window.dispatchEvent(new Event('features-changed')); 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 return false; // Don't show readonly fields in edit form
} }
// Also filter out common non-editable fields // Also filter out common non-editable fields
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
return !nonEditableFields.includes(attr.name); return !nonEditableFields.includes(attr.name);
}) })
.map(attr => { .map(attr => {
@ -367,7 +367,7 @@ export function usePrompts() {
return false; return false;
} }
// Filter out ID fields and other auto-generated fields // 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); return !nonEditableFields.includes(attr.name);
}) })
.map(attr => { .map(attr => {
@ -530,7 +530,7 @@ export function usePromptOperations() {
try { try {
// Pass all provided fields (supports partial inline updates like isSystem toggle) // 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); 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 => { .filter(attr => {
if (attr.readonly === true || attr.editable === false) return false; if (attr.readonly === true || attr.editable === false) return false;
if (attr.name === 'id') 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); return !nonEditable.includes(attr.name);
}) })
.map(attr => { .map(attr => {
@ -210,7 +210,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
const generateCreateFieldsFromAttributes = useCallback(() => { const generateCreateFieldsFromAttributes = useCallback(() => {
if (!attributes || attributes.length === 0) return []; if (!attributes || attributes.length === 0) return [];
return attributes 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 => { .map(attr => {
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string'; let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
let options: Array<{ value: string | number; label: string }> | undefined; let options: Array<{ value: string | number; label: string }> | undefined;

View file

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

View file

@ -42,10 +42,11 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
const [plan, setPlan] = useState<SubscriptionPlan | null>(null); const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
const [scheduled, setScheduled] = useState<MandateSubscription | null>(null); const [scheduled, setScheduled] = useState<MandateSubscription | null>(null);
const [active, setActive] = useState(false); 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 [error, setError] = useState<string | null>(null);
const loadPlans = useCallback(async () => { const loadPlans = useCallback(async () => {
clearCache('/api/subscription/plans', 'get');
try { try {
const data = await fetchSelectablePlans(request, mandateId); const data = await fetchSelectablePlans(request, mandateId);
setPlans(Array.isArray(data) ? data : []); setPlans(Array.isArray(data) ? data : []);
@ -53,9 +54,10 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
console.error('Error loading plans:', err); console.error('Error loading plans:', err);
setPlans([]); setPlans([]);
} }
}, [request, mandateId]); }, [request, mandateId, clearCache]);
const loadStatus = useCallback(async () => { const loadStatus = useCallback(async () => {
clearCache('/api/subscription/status', 'get');
try { try {
const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId); const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId);
setActive(data.active); setActive(data.active);
@ -69,7 +71,7 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
setPlan(null); setPlan(null);
setScheduled(null); setScheduled(null);
} }
}, [request, mandateId]); }, [request, mandateId, clearCache]);
const activatePlan = useCallback(async (planKey: string) => { const activatePlan = useCallback(async (planKey: string) => {
try { try {

View file

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

View file

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

View file

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

View file

@ -176,7 +176,7 @@ export function useTrusteeDocuments() {
return false; return false;
} }
// documentData is handled separately (binary upload) // 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); return !nonEditableFields.includes(attr.name);
}) })
.map(attr => { .map(attr => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -99,6 +99,7 @@
/* Let child components handle their own scrolling for sticky headers */ /* Let child components handle their own scrolling for sticky headers */
overflow: hidden; overflow: hidden;
background: var(--bg-primary, #ffffff); background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1a1a1a);
} }
/* Fills .content flex column so admin pages get a bounded height for inner scroll */ /* Fills .content flex column so admin pages get a bounded height for inner scroll */
@ -168,6 +169,7 @@
:global(.dark-theme) .content { :global(.dark-theme) .content {
background: var(--bg-dark, #0a0a0a); background: var(--bg-dark, #0a0a0a);
color: var(--text-primary, #e5e7eb);
} }
:global(.dark-theme) .mobileMenuButton { :global(.dark-theme) .mobileMenuButton {

View file

@ -11,9 +11,11 @@ import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection'; import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'; import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import styles from './MainLayout.module.css'; import styles from './MainLayout.module.css';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/; const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
// ============================================================================= // =============================================================================
// INNER LAYOUT (mit Zugriff auf Store) // INNER LAYOUT (mit Zugriff auf Store)
@ -23,6 +25,9 @@ const MainLayoutInner: React.FC = () => {
const { loadFeatures, initialized, loading, error } = useFeatureStore(); const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation(); const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); 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 // Features laden beim Mount
useEffect(() => { useEffect(() => {
@ -105,11 +110,12 @@ const MainLayoutInner: React.FC = () => {
/> />
</div> </div>
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} /> <WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
<div <div
className={styles.outletShell} className={styles.outletShell}
style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : undefined }} style={{ display: hideOutletShell ? 'none' : undefined }}
> >
<Outlet /> <Outlet />
</div> </div>

View file

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

View file

@ -242,6 +242,50 @@
text-decoration: underline; 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 { button:disabled {
opacity: 0.7; opacity: 0.7;
cursor: not-allowed; 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 { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage'; import { PENDING_INVITATION_KEY } from './InvitePage';
import OnboardingWizard from '../components/OnboardingWizard';
import styles from './Login.module.css'; import styles from './Login.module.css';
@ -21,13 +22,14 @@ function Login() {
const { login, error: loginError, isLoading: isLoginLoading } = useAuth(); const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth(); const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
// Check for pending invitation // Check for pending invitation
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken; const hasPendingInvitation = !!pendingInvitationToken;
// Get the page the user was trying to visit const fromLocation = location.state?.from;
const from = location.state?.from?.pathname || "/"; const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
// Set page title and generate CSRF token // Set page title and generate CSRF token
useEffect(() => { useEffect(() => {
@ -84,6 +86,10 @@ function Login() {
console.log("Attempting Google login..."); console.log("Attempting Google login...");
const response = await loginWithGoogle(); const response = await loginWithGoogle();
console.log("Google login successful:", response); console.log("Google login successful:", response);
if (response?.isNewUser) {
setShowOnboardingWizard(true);
return;
}
handleSuccessfulLogin(); handleSuccessfulLogin();
} catch (error) { } catch (error) {
console.error("Google login failed:", 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 ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
@ -213,12 +234,15 @@ function Login() {
</button> </button>
<div className={styles.registerLink}> <div className={styles.registerLink}>
<span>Du hast noch keinen Konto?</span> <span>Du hast noch kein Konto?</span>
</div>
<div className={styles.ctaSection}>
<button <button
className={styles.textButton} type="button"
onClick={() => navigate("/register", { state: location.state })} className={styles.ctaPrimary}
onClick={() => navigate('/register', { state: location.state })}
> >
Registrieren Kostenlos registrieren
</button> </button>
</div> </div>
</div> </div>

View file

@ -19,7 +19,6 @@ function Register() {
const { register, error: registerError, isLoading } = useRegister(); const { register, error: registerError, isLoading } = useRegister();
const { error: msalError } = useMsalRegister(); const { error: msalError } = useMsalRegister();
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
// Pre-fill from invitation if provided via location.state
const invitationUsername = (location.state as any)?.invitationUsername || ''; const invitationUsername = (location.state as any)?.invitationUsername || '';
const invitationEmail = (location.state as any)?.invitationEmail || ''; const invitationEmail = (location.state as any)?.invitationEmail || '';
const [formData, setFormData] = useState<RegisterFormData>({ const [formData, setFormData] = useState<RegisterFormData>({
@ -34,15 +33,11 @@ function Register() {
const [fullNameFocused, setFullNameFocused] = useState(false); const [fullNameFocused, setFullNameFocused] = useState(false);
const [usernameHighlight, setUsernameHighlight] = useState(false); const [usernameHighlight, setUsernameHighlight] = useState(false);
// Check for pending invitation
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken; const hasPendingInvitation = !!pendingInvitationToken;
// Set page title and generate CSRF token
useEffect(() => { useEffect(() => {
document.title = "PowerOn AI Platform - Registrieren"; document.title = "PowerOn AI Platform - Registrieren";
// Generate CSRF token for new security implementation
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
}, []); }, []);
@ -53,13 +48,12 @@ function Register() {
[name]: value [name]: value
})); }));
setValidationError(null); setValidationError(null);
// Reset username highlight when user starts typing in username field
if (name === 'username') { if (name === 'username') {
setUsernameHighlight(false); setUsernameHighlight(false);
} }
}; };
const validateForm = (): boolean => { const _validateForm = (): boolean => {
if (!formData.username || !formData.email || !formData.fullName) { if (!formData.username || !formData.email || !formData.fullName) {
setValidationError('Bitte füllen Sie alle Pflichtfelder aus.'); setValidationError('Bitte füllen Sie alle Pflichtfelder aus.');
return false; return false;
@ -76,16 +70,14 @@ function Register() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!validateForm()) { if (!_validateForm()) {
return; return;
} }
try { try {
// First check username availability
const availabilityResult = await checkAvailability(formData.username, 'local'); const availabilityResult = await checkAvailability(formData.username, 'local');
if (!availabilityResult.available) { if (!availabilityResult.available) {
// Check if the error message is about username being taken
const errorMessage = availabilityResult.message || 'Username is not available'; const errorMessage = availabilityResult.message || 'Username is not available';
if (errorMessage === 'Username is already taken') { if (errorMessage === 'Username is already taken') {
setValidationError('Benutzername ist bereits vergeben'); setValidationError('Benutzername ist bereits vergeben');
@ -96,25 +88,20 @@ function Register() {
return; return;
} }
// Username is available, proceed with registration (no password - magic link flow) await register({ ...formData, registrationType: 'personal' });
await register(formData);
// 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.'; 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) { if (hasPendingInvitation) {
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.'; message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
} }
// Show success message instead of immediate redirect
setSuccessMessage(message); setSuccessMessage(message);
// Redirect to login page after delay
setTimeout(() => { setTimeout(() => {
navigate('/login', { navigate('/login', {
state: { state: {
registered: true, registered: true,
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.', message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
// Pass along invitation state
...(location.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 (validationError) return validationError;
if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed'; if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed';
if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft 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.loginSection}>
<div className={styles.loginBox}> <div className={styles.loginBox}>
<div className={styles.loginForm}> <div className={styles.loginForm}>
{/* Pending invitation notice */}
{hasPendingInvitation && !successMessage && ( {hasPendingInvitation && !successMessage && (
<div className={styles.invitationNotice}> <div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} /> <FaEnvelopeOpenText className={styles.invitationIcon} />
@ -154,8 +139,8 @@ function Register() {
</div> </div>
)} )}
{getErrorMessage() && ( {_getErrorMessage() && (
<div className={styles.error}>{getErrorMessage()}</div> <div className={styles.error}>{_getErrorMessage()}</div>
)} )}
{successMessage && ( {successMessage && (
@ -221,7 +206,7 @@ function Register() {
onClick={handleSubmit} onClick={handleSubmit}
disabled={isLoading || isChecking} 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> </button>
</> </>
)} )}

View file

@ -1,18 +1,32 @@
/** /**
* Settings Page * Settings Page User-level settings with tabs.
* * Route: /settings
* Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext).
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useCurrentUser, useUser } from '../hooks/useUsers'; import { useCurrentUser, useUser } from '../hooks/useUsers';
import { setUserDataCache, getUserDataCache } from '../utils/userCache'; import { setUserDataCache, getUserDataCache } from '../utils/userCache';
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import { useApiRequest } from '../hooks/useApi';
import styles from './Settings.module.css'; 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 // PROFILE EDIT MODAL
// ============================================================================= // =============================================================================
@ -27,39 +41,13 @@ interface ProfileEditModalProps {
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => { const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Define editable profile fields
const profileAttributes: AttributeDefinition[] = [ const profileAttributes: AttributeDefinition[] = [
{ { name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' },
name: 'fullName', { name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' },
type: 'string', { 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' }] },
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' }
]
}
]; ];
const handleSubmit = async (formData: any) => { const handleSubmit = async (formData: any) => {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
@ -72,9 +60,9 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
setIsSaving(false); setIsSaving(false);
} }
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className={styles.modalOverlay} onClick={onClose}> <div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}> <div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
@ -84,21 +72,358 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
</div> </div>
<div className={styles.modalBody}> <div className={styles.modalBody}>
{error && <div className={styles.errorMessage}>{error}</div>} {error && <div className={styles.errorMessage}>{error}</div>}
<FormGeneratorForm <FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? 'Speichern...' : 'Speichern'} cancelButtonText="Abbrechen" />
attributes={profileAttributes}
data={userData}
mode="edit"
onSubmit={handleSubmit}
onCancel={onClose}
submitButtonText={isSaving ? 'Speichern...' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
</div> </div>
</div> </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 // SETTINGS PAGE
// ============================================================================= // =============================================================================
@ -107,266 +432,142 @@ export const SettingsPage: React.FC = () => {
const { currentLanguage, setLanguage } = useLanguage(); const { currentLanguage, setLanguage } = useLanguage();
const { user: currentUser, refetch: refetchUser } = useCurrentUser(); const { user: currentUser, refetch: refetchUser } = useCurrentUser();
const { updateUser } = useUser(); const { updateUser } = useUser();
const [theme, setTheme] = useState<'light' | 'dark'>( const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light' const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
);
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
const [isSavingLanguage, setIsSavingLanguage] = useState(false); const [isSavingLanguage, setIsSavingLanguage] = useState(false);
const [languageError, setLanguageError] = useState<string | null>(null); const [languageError, setLanguageError] = useState<string | null>(null);
// Handle theme change
const handleThemeChange = (newTheme: 'light' | 'dark') => { const handleThemeChange = (newTheme: 'light' | 'dark') => {
setTheme(newTheme); setTheme(newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);
if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); }
if (newTheme === 'dark') { else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); }
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); document.documentElement.setAttribute('data-theme', newTheme);
}; };
// Handle language change - save to backend and update cache
const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => { const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => {
if (!currentUser?.id || !currentUser?.username) return; if (!currentUser?.id || !currentUser?.username) return;
setIsSavingLanguage(true); setIsSavingLanguage(true);
setLanguageError(null); setLanguageError(null);
try { try {
// 1. Build full user object for update (backend requires full User model) 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 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
const cachedUser = getUserDataCache(); const cachedUser = getUserDataCache();
if (cachedUser) { if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage });
setUserDataCache({ ...cachedUser, language: newLanguage });
}
// 4. Update UI language context
setLanguage(newLanguage); setLanguage(newLanguage);
// 5. Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('userInfoUpdated')); window.dispatchEvent(new CustomEvent('userInfoUpdated'));
} catch { setLanguageError('Sprache konnte nicht gespeichert werden'); }
console.log('Language updated successfully to:', newLanguage); finally { setIsSavingLanguage(false); }
} catch (err: any) {
console.error('Failed to update language:', err);
setLanguageError('Sprache konnte nicht gespeichert werden');
} finally {
setIsSavingLanguage(false);
}
}, [currentUser, updateUser, setLanguage]); }, [currentUser, updateUser, setLanguage]);
// Handle profile save
const handleProfileSave = useCallback(async (formData: any) => { const handleProfileSave = useCallback(async (formData: any) => {
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet'); 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'; const newLanguage = formData.language || currentUser.language || 'de';
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' });
// 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 cachedUser = getUserDataCache(); const cachedUser = getUserDataCache();
if (cachedUser) { if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage });
setUserDataCache({ if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr');
...cachedUser, if (refetchUser) await refetchUser();
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)
window.dispatchEvent(new CustomEvent('userInfoUpdated')); window.dispatchEvent(new CustomEvent('userInfoUpdated'));
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]); }, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
return ( return (
<div className={styles.settings}> <div className={styles.settings}>
<header className={styles.header}> <header className={styles.header}>
<h1>Einstellungen</h1> <h1>Einstellungen</h1>
<p className={styles.subtitle}>Persönliche Einstellungen und Präferenzen</p> <p className={styles.subtitle}>Persoenliche Einstellungen und Praeferenzen</p>
</header> </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}> <main className={styles.content}>
{/* Darstellung */} {activeTab === 'profile' && (
<section className={styles.section}> <>
<h2 className={styles.sectionTitle}>Darstellung</h2> <section className={styles.section}>
<h2 className={styles.sectionTitle}>Konto</h2>
<div className={styles.settingRow}> <div className={styles.settingRow}>
<div className={styles.settingInfo}> <div className={styles.settingInfo}>
<label className={styles.settingLabel}>Theme</label> <label className={styles.settingLabel}>Profil bearbeiten</label>
<p className={styles.settingDescription}> <p className={styles.settingDescription}>Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.</p>
Wähle zwischen hellem und dunklem Design. </div>
</p> <div className={styles.settingControl}>
</div> <button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>Profil oeffnen</button>
<div className={styles.settingControl}> </div>
<div className={styles.themeToggle}> </div>
<button {currentUser && (
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} <div className={styles.userInfoCard}>
onClick={() => handleThemeChange('light')} <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>
Hell <div className={styles.userInfoRow}><span className={styles.userInfoLabel}>E-Mail</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
</button> </div>
<button )}
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} </section>
onClick={() => handleThemeChange('dark')} <section className={styles.section}>
> <h2 className={styles.sectionTitle}>Ueber</h2>
Dunkel <div className={styles.infoCard}>
</button> <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>
</div> <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.settingRow}> <div className={styles.settingControl}>
<div className={styles.settingInfo}> <select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')} disabled={isSavingLanguage}>
<label className={styles.settingLabel}>Sprache</label> <option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
<p className={styles.settingDescription}> </select>
Wähle die Anzeigesprache der Anwendung. {isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
{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> </div>
</div> </div>
)} </section>
</section> )}
{/* Datenschutz */} {activeTab === 'voice' && <VoiceSettingsTab />}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Datenschutz</h2> {activeTab === 'neutralization' && <NeutralizationMappingsTab />}
<div className={styles.settingRow}> {activeTab === 'privacy' && (
<div className={styles.settingInfo}> <section className={styles.section}>
<label className={styles.settingLabel}>GDPR / Privacy</label> <h2 className={styles.sectionTitle}>Datenschutz</h2>
<p className={styles.settingDescription}> <p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
Data export, portability and account deletion. Feature-Daten (z.&nbsp;B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich
</p> 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>
<div className={styles.settingControl}> </section>
<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>
</main> </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> </div>
); );
}; };

View file

@ -29,6 +29,52 @@
font-size: 0.9375rem; 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 */
.grid { .grid {
display: grid; display: grid;
@ -120,8 +166,54 @@
background: currentColor; 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 */ /* Actions */
.cardActions { .cardActions {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.5rem; padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0); border-top: 1px solid var(--border-color, #e0e0e0);
} }
@ -243,17 +335,35 @@
border-top-color: var(--border-dark, #333); 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); border-color: var(--border-dark, #444);
color: var(--text-secondary-dark, #aaa); 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); border-color: var(--error-color-dark, #f87171);
color: var(--error-color-dark, #f87171); color: var(--error-color-dark, #f87171);
background: rgba(248, 113, 113, 0.1); 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 { :global(.dark-theme) .error {
background: var(--error-bg-dark, #450a0a); background: var(--error-bg-dark, #450a0a);
border-color: var(--error-border-dark, #991b1b); border-color: var(--error-border-dark, #991b1b);

View file

@ -1,16 +1,15 @@
/** /**
* Store Page * Feature Store -- Users activate feature instances in their own mandates.
* * Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance
* Feature Store where users can self-activate features in the root mandate. * in the selected mandate. Explicit mandate selection required.
* Uses the Shared Instance Pattern -- each feature has one shared instance,
* and users get their own FeatureAccess + user-role upon activation.
*/ */
import React from 'react'; import React from 'react';
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore'; 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'; import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = { const FEATURE_ICONS: Record<string, React.ReactNode> = {
@ -62,23 +61,27 @@ function _getDescription(featureCode: string, lang: string): string {
interface FeatureCardProps { interface FeatureCardProps {
feature: StoreFeature; feature: StoreFeature;
language: string; language: string;
mandates: UserMandate[];
actionLoading: string | null; actionLoading: string | null;
onActivate: (code: string) => void; onActivate: (code: string, mandateId?: string) => void;
onDeactivate: (code: string) => void; onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
} }
const FeatureCard: React.FC<FeatureCardProps> = ({ const FeatureCard: React.FC<FeatureCardProps> = ({
feature, feature,
language, language,
mandates,
actionLoading, actionLoading,
onActivate, onActivate,
onDeactivate, onDeactivate,
}) => { }) => {
const isProcessing = actionLoading === feature.featureCode; const isProcessing = actionLoading === feature.featureCode;
const icon = FEATURE_ICONS[feature.featureCode]; const icon = FEATURE_ICONS[feature.featureCode];
const activeInstances = feature.instances.filter(inst => inst.isActive);
const hasActive = activeInstances.length > 0;
return ( return (
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}> <div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
{icon && <span className={styles.cardIcon}>{icon}</span>} {icon && <span className={styles.cardIcon}>{icon}</span>}
<h3 className={styles.cardTitle}> <h3 className={styles.cardTitle}>
@ -92,37 +95,56 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
</p> </p>
</div> </div>
<div> {activeInstances.length > 0 && (
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}> <div className={styles.instanceList}>
<span className={styles.statusDot} /> {activeInstances.map((inst) => (
{feature.isActive <div key={inst.instanceId} className={styles.instanceRow}>
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active') <div className={styles.instanceInfo}>
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')} <span className={`${styles.statusBadge} ${styles.statusActive}`}>
</span> <span className={styles.statusDot} />
</div> {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}> <div className={styles.cardActions}>
{feature.isActive ? ( {feature.canActivate && mandates.map((m) => (
<button <button
className={styles.deactivateButton} key={m.id}
onClick={() => onDeactivate(feature.featureCode)} className={styles.activateButton}
onClick={() => onActivate(feature.featureCode, m.id)}
disabled={isProcessing} 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 {isProcessing
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...') ? (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> </button>
)} ))}
</div> </div>
</div> </div>
); );
@ -130,7 +152,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
const StorePage: React.FC = () => { const StorePage: React.FC = () => {
const { currentLanguage } = useLanguage(); const { currentLanguage } = useLanguage();
const { features, loading, actionLoading, error, activate, deactivate } = useStore(); const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
return ( return (
<div className={styles.store}> <div className={styles.store}>
@ -145,6 +167,33 @@ const StorePage: React.FC = () => {
</p> </p>
</div> </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>} {error && <div className={styles.error}>{error}</div>}
{loading ? ( {loading ? (
@ -164,6 +213,7 @@ const StorePage: React.FC = () => {
key={feature.featureCode} key={feature.featureCode}
feature={feature} feature={feature}
language={currentLanguage} language={currentLanguage}
mandates={mandates}
actionLoading={actionLoading} actionLoading={actionLoading}
onActivate={activate} onActivate={activate}
onDeactivate={deactivate} onDeactivate={deactivate}

View file

@ -550,8 +550,8 @@
.statusBadge.starting, .statusBadge.starting,
.statusBadge.running { .statusBadge.running {
background: #e3f2fd; background: var(--primary-dark-bg, rgba(242, 88, 67, 0.12));
color: #1976d2; color: var(--primary-color, #F25843);
} }
.statusBadge.completed { .statusBadge.completed {
@ -617,7 +617,7 @@
} }
.logStatus { .logStatus {
color: #1976d2; color: var(--primary-color, #F25843);
} }
.logEntryError .logStatus, .logEntryError .logStatus,

View file

@ -14,15 +14,17 @@ import {
splitMandateAndBillingFromForm, splitMandateAndBillingFromForm,
} from '../../utils/mandateBillingFormMerge'; } from '../../utils/mandateBillingFormMerge';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; 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'; import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => { export const AdminMandatesPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { showWarning, showSuccess } = useToast(); const { showWarning, showSuccess } = useToast();
const { prompt, PromptDialog } = usePrompt();
const { const {
mandates, mandates,
columns, columns,
@ -35,6 +37,7 @@ export const AdminMandatesPage: React.FC = () => {
handleCreate, handleCreate,
handleUpdate, handleUpdate,
handleDelete, handleDelete,
handleHardDelete,
handleInlineUpdate, handleInlineUpdate,
updateOptimistically, updateOptimistically,
} = useAdminMandates(); } = useAdminMandates();
@ -111,15 +114,42 @@ export const AdminMandatesPage: React.FC = () => {
setEditingBillingWarning(null); setEditingBillingWarning(null);
}; };
// Handle delete (confirmation handled by DeleteActionButton)
// System mandates (isSystem=true) are protected from deletion
const handleDeleteMandate = async (mandate: Mandate) => { const handleDeleteMandate = async (mandate: Mandate) => {
if (mandate.isSystem) { 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); 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) { if (error) {
return ( return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}> <div className={`${styles.adminPage} ${styles.adminPageFill}`}>
@ -209,12 +239,21 @@ export const AdminMandatesPage: React.FC = () => {
}] : []), }] : []),
...(canDelete ? [{ ...(canDelete ? [{
type: 'delete' as const, type: 'delete' as const,
title: 'Löschen', title: 'Deaktivieren (Soft-Delete)',
disabled: (row: Mandate) => row.isSystem disabled: (row: Mandate) => row.isSystem
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' } ? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
: false : 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} onDelete={handleDeleteMandate}
hookData={{ hookData={{
refetch, refetch,
@ -267,6 +306,8 @@ export const AdminMandatesPage: React.FC = () => {
</div> </div>
)} )}
<PromptDialog />
{/* Edit Modal */} {/* Edit Modal */}
{editingFormData && ( {editingFormData && (
<div <div

View file

@ -4,7 +4,7 @@
* 4-step invitation wizard: * 4-step invitation wizard:
* 1. Choose invite type: "Invite to mandate" OR "Invite to feature instance" * 1. Choose invite type: "Invite to mandate" OR "Invite to feature instance"
* 2. Select mandate (and feature instance if applicable) * 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 * 4. Summary and send
*/ */
@ -167,13 +167,24 @@ export const AdminInvitationWizardPage: React.FC = () => {
const addInviteeByEmail = () => { const addInviteeByEmail = () => {
const email = inviteeForm.email.trim(); const email = inviteeForm.email.trim();
if (!email) { const username = inviteeForm.username.trim();
setError('E-Mail ist erforderlich'); 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; return;
} }
setInvitees(prev => [...prev, { setInvitees(prev => [...prev, {
email, email,
username: undefined, username: username || undefined,
roleIds: [...inviteeForm.roleIds], roleIds: [...inviteeForm.roleIds],
isExisting: false, isExisting: false,
}]); }]);
@ -189,10 +200,6 @@ export const AdminInvitationWizardPage: React.FC = () => {
const user = allSystemUsers.find(u => u.id === selectedExistingUserId); const user = allSystemUsers.find(u => u.id === selectedExistingUserId);
if (!user) return; if (!user) return;
const email = (user.email || '').trim(); const email = (user.email || '').trim();
if (!email) {
setError('Dieser Benutzer hat keine E-Mail-Adresse');
return;
}
if (invitees.some(i => i.userId === user.id)) { if (invitees.some(i => i.userId === user.id)) {
setError('Dieser Benutzer ist bereits in der Liste'); setError('Dieser Benutzer ist bereits in der Liste');
return; return;
@ -232,8 +239,9 @@ export const AdminInvitationWizardPage: React.FC = () => {
const results: DispatchResult[] = []; const results: DispatchResult[] = [];
try { try {
for (const inv of invitees) { for (const inv of invitees) {
const emailTrim = (inv.email || '').trim();
const payload = { const payload = {
email: inv.email, ...(emailTrim ? { email: emailTrim } : {}),
targetUsername: inv.username || undefined, targetUsername: inv.username || undefined,
roleIds: inv.roleIds, roleIds: inv.roleIds,
expiresInHours: EXPIRES_IN_HOURS, expiresInHours: EXPIRES_IN_HOURS,
@ -244,14 +252,14 @@ export const AdminInvitationWizardPage: React.FC = () => {
const result = await createInvitation(selectedMandate.id, payload); const result = await createInvitation(selectedMandate.id, payload);
if (result.success) { if (result.success) {
results.push({ results.push({
email: inv.email, email: emailTrim,
username: inv.username, username: inv.username,
success: true, success: true,
emailSent: result.data?.emailSent, emailSent: result.data?.emailSent,
}); });
} else { } else {
results.push({ results.push({
email: inv.email, email: emailTrim,
username: inv.username, username: inv.username,
success: false, success: false,
error: result.error, error: result.error,
@ -452,7 +460,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
<div style={_cardStyle}> <div style={_cardStyle}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>Einladungen hinzufügen</h3> <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' }}> <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> </p>
{/* Add form: toggle email vs existing */} {/* Add form: toggle email vs existing */}
@ -462,7 +470,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
style={{ fontSize: '12px', padding: '6px 12px' }} style={{ fontSize: '12px', padding: '6px 12px' }}
onClick={() => setAddMode('email')} onClick={() => setAddMode('email')}
> >
Per E-Mail (neue Benutzer) Neue Benutzer (E-Mail und/oder Benutzername)
</button> </button>
<button <button
className={addMode === 'existing' ? styles.primaryButton : styles.secondaryButton} className={addMode === 'existing' ? styles.primaryButton : styles.secondaryButton}
@ -476,7 +484,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
{addMode === 'email' ? ( {addMode === 'email' ? (
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}> <div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
<div> <div>
<label className={`${styles.formLabel} ${styles.required}`}>E-Mail *</label> <label className={styles.formLabel}>E-Mail (optional)</label>
<input <input
className={styles.formInput} className={styles.formInput}
type="email" type="email"
@ -484,8 +492,19 @@ export const AdminInvitationWizardPage: React.FC = () => {
onChange={e => setInviteeForm(p => ({ ...p, email: e.target.value }))} onChange={e => setInviteeForm(p => ({ ...p, email: e.target.value }))}
placeholder="beispiel@firma.com" 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' }}> <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> </p>
</div> </div>
{roles.length > 0 && ( {roles.length > 0 && (
@ -497,7 +516,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
display: 'flex', alignItems: 'center', gap: '6px', display: 'flex', alignItems: 'center', gap: '6px',
padding: '6px 12px', borderRadius: '6px', padding: '6px 12px', borderRadius: '6px',
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)', 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', fontSize: '12px', cursor: 'pointer',
}}> }}>
<input <input
@ -519,7 +538,10 @@ export const AdminInvitationWizardPage: React.FC = () => {
<button <button
className={styles.primaryButton} className={styles.primaryButton}
onClick={addInviteeByEmail} 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 Hinzufügen
</button> </button>
@ -552,7 +574,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
display: 'flex', alignItems: 'center', gap: '6px', display: 'flex', alignItems: 'center', gap: '6px',
padding: '6px 12px', borderRadius: '6px', padding: '6px 12px', borderRadius: '6px',
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)', 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', fontSize: '12px', cursor: 'pointer',
}}> }}>
<input <input
@ -586,7 +608,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}> <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' }}>Benutzername</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th> <th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th>
<th style={{ textAlign: 'left', padding: '8px' }}>Typ</th> <th style={{ textAlign: 'left', padding: '8px' }}>Typ</th>
@ -596,14 +618,16 @@ export const AdminInvitationWizardPage: React.FC = () => {
<tbody> <tbody>
{invitees.map((inv, idx) => ( {invitees.map((inv, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}> <tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
<td style={{ padding: '8px' }}>{inv.email}</td> <td style={{ padding: '8px' }}>{inv.email || '—'}</td>
<td style={{ padding: '8px', color: 'var(--text-secondary)' }}>{inv.isExisting ? inv.username : ''}</td> <td style={{ padding: '8px', color: 'var(--text-secondary)' }}>
{inv.username || ''}
</td>
<td style={{ padding: '8px' }}> <td style={{ padding: '8px' }}>
{inv.roleIds.length > 0 {inv.roleIds.length > 0
? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ') ? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')
: '-'} : '-'}
</td> </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' }}> <td style={{ padding: '8px', textAlign: 'right' }}>
<button <button
onClick={() => removeInvitee(idx)} onClick={() => removeInvitee(idx)}
@ -654,7 +678,8 @@ export const AdminInvitationWizardPage: React.FC = () => {
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}> <ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
{invitees.map((inv, i) => ( {invitees.map((inv, i) => (
<li key={i} style={{ marginBottom: '4px' }}> <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(', ')}`} {inv.roleIds.length > 0 && ` ${roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')}`}
</li> </li>
))} ))}
@ -680,7 +705,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}> <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' }}>Status</th>
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail gesendet</th> <th style={{ textAlign: 'left', padding: '8px' }}>E-Mail gesendet</th>
</tr> </tr>
@ -688,7 +713,11 @@ export const AdminInvitationWizardPage: React.FC = () => {
<tbody> <tbody>
{dispatchResults.map((r, idx) => ( {dispatchResults.map((r, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}> <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' }}> <td style={{ padding: '8px' }}>
<span style={{ <span style={{
padding: '2px 8px', borderRadius: '4px', fontSize: '12px', padding: '2px 8px', borderRadius: '4px', fontSize: '12px',

View file

@ -236,6 +236,7 @@ export const AdminMandateWizardPage: React.FC = () => {
if (billingSaved) { if (billingSaved) {
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert'); showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
} }
window.dispatchEvent(new CustomEvent('features-changed'));
await loadMandates(); await loadMandates();
} catch (err: unknown) { } catch (err: unknown) {
const e = err as { response?: { data?: { detail?: string } }; message?: string }; 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 { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css'; 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 = () => { export const ConnectionsPage: React.FC = () => {
// Use the consolidated hook // Use the consolidated hook
const { 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 () => { const handleCreateClickup = async () => {
if (!isClickupConnectionUiEnabled) return;
try { try {
await createClickupConnectionAndAuth(); await createClickupConnectionAndAuth();
refetch(); refetch();
@ -220,7 +224,7 @@ export const ConnectionsPage: React.FC = () => {
// Form attributes for edit modal // Form attributes for edit modal
const formAttributes = useMemo(() => { 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 || []) return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name)); .filter(attr => !excludedFields.includes(attr.name));
}, [attributes]); }, [attributes]);
@ -244,7 +248,10 @@ export const ConnectionsPage: React.FC = () => {
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h1 className={styles.pageTitle}>Verbindungen</h1> <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>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button <button
@ -278,14 +285,17 @@ export const ConnectionsPage: React.FC = () => {
> >
<FaMicrosoft /> Microsoft <FaMicrosoft /> Microsoft
</button> </button>
<button {isClickupConnectionUiEnabled && (
className={styles.clickupButton} <button
onClick={handleCreateClickup} type="button"
disabled={isConnecting} className={styles.clickupButton}
title="ClickUp-Konto verbinden" onClick={handleCreateClickup}
> disabled={isConnecting}
<FaTasks /> ClickUp title="ClickUp-Konto verbinden"
</button> >
<FaTasks /> ClickUp
</button>
)}
</> </>
)} )}
</div> </div>
@ -302,7 +312,9 @@ export const ConnectionsPage: React.FC = () => {
<FaPlug className={styles.emptyIcon} /> <FaPlug className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3> <h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
<p className={styles.emptyDescription}> <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> </p>
{canCreate && ( {canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
@ -320,13 +332,16 @@ export const ConnectionsPage: React.FC = () => {
> >
<FaMicrosoft /> Mit Microsoft verbinden <FaMicrosoft /> Mit Microsoft verbinden
</button> </button>
<button {isClickupConnectionUiEnabled && (
className={styles.clickupButton} <button
onClick={handleCreateClickup} type="button"
disabled={isConnecting} className={styles.clickupButton}
> onClick={handleCreateClickup}
<FaTasks /> Mit ClickUp verbinden disabled={isConnecting}
</button> >
<FaTasks /> Mit ClickUp verbinden
</button>
)}
</div> </div>
)} )}
</div> </div>
@ -360,7 +375,9 @@ export const ConnectionsPage: React.FC = () => {
icon: <FaLink />, icon: <FaLink />,
onClick: handleConnect, onClick: handleConnect,
title: 'Verbinden', title: 'Verbinden',
visible: (row: Connection) => row.status !== 'active', visible: (row: Connection) =>
row.status !== 'active' &&
(isClickupConnectionUiEnabled || row.authority !== 'clickup'),
loading: () => isConnecting, loading: () => isConnecting,
}, },
{ {

View file

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

View file

@ -53,7 +53,7 @@ export const PromptsPage: React.FC = () => {
// Generate columns from attributes - exclude ID fields from display // Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => { const columns = useMemo(() => {
// Fields to hide in table view // 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 || []) const cols = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name)) .filter(attr => !hiddenColumns.includes(attr.name))
@ -71,9 +71,9 @@ export const PromptsPage: React.FC = () => {
fkDisplayField: (attr as any).fkDisplayField, 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({ cols.push({
key: '_createdBy', key: 'sysCreatedBy',
label: 'Created By', label: 'Created By',
type: 'text' as any, type: 'text' as any,
sortable: true, sortable: true,
@ -148,7 +148,7 @@ export const PromptsPage: React.FC = () => {
// Form attributes for create/edit modal // Form attributes for create/edit modal
const formAttributes = useMemo(() => { 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 || []) return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name)); .filter(attr => !excludedFields.includes(attr.name));
}, [attributes]); }, [attributes]);

View file

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

View file

@ -85,10 +85,11 @@ interface SettingsEditorProps {
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => { const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
const [formData, setFormData] = useState({ 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), warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
notifyOnWarning: settings?.notifyOnWarning ?? true, notifyOnWarning: settings?.notifyOnWarning ?? true,
autoRechargeEnabled: settings?.autoRechargeEnabled ?? false,
rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10),
rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3),
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@ -96,10 +97,11 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
setFormData({ setFormData({
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
notifyOnWarning: settings.notifyOnWarning ?? true, notifyOnWarning: settings.notifyOnWarning ?? true,
autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
}); });
} }
}, [settings]); }, [settings]);
@ -130,32 +132,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
)} )}
<form onSubmit={handleSubmit}> <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.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>Warnschwelle (%)</label> <label>Warnschwelle (%)</label>
@ -184,6 +160,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
</label> </label>
</div> </div>
</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 <button
type="submit" type="submit"
@ -202,28 +221,15 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
// ============================================================================ // ============================================================================
interface CreditAdderProps { interface CreditAdderProps {
settings: BillingSettings | null;
accounts: AccountSummary[];
users: MandateUserSummary[];
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>; onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
} }
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => { const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [amount, setAmount] = useState<string>(''); const [amount, setAmount] = useState<string>('');
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin'); const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); 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) => { const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const numAmount = parseFloat(amount); const numAmount = parseFloat(amount);
@ -236,7 +242,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
setMessage(null); setMessage(null);
try { try {
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); await onAddCredit(undefined, numAmount, description);
const label = numAmount > 0 const label = numAmount > 0
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` ? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`; : `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
@ -260,31 +266,6 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
)} )}
<form onSubmit={_handleSubmit}> <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.formRow}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label>Betrag (CHF)</label> <label>Betrag (CHF)</label>
@ -313,7 +294,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
<button <button
type="submit" type="submit"
className={`${styles.button} ${styles.buttonPrimary}`} className={`${styles.button} ${styles.buttonPrimary}`}
disabled={saving || (isPrepayUser && !selectedUserId) || !amount} disabled={saving || !amount}
> >
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')} {saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
</button> </button>
@ -328,11 +309,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
interface AccountsOverviewProps { interface AccountsOverviewProps {
accounts: AccountSummary[]; accounts: AccountSummary[];
users: MandateUserSummary[]; /** Kept for call-site compatibility; only mandate pool accounts are shown. */
users?: MandateUserSummary[];
loading: boolean; loading: boolean;
} }
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, loading }) => { const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('de-CH', { return new Intl.NumberFormat('de-CH', {
style: 'currency', style: 'currency',
@ -340,19 +322,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
}).format(amount); }).format(amount);
}; };
// Build a lookup map: userId -> display name const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
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]);
if (loading) { if (loading) {
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>; return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
} }
@ -360,16 +331,19 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
if (accounts.length === 0) { if (accounts.length === 0) {
return <div className={styles.noData}>Keine Konten vorhanden</div>; return <div className={styles.noData}>Keine Konten vorhanden</div>;
} }
if (poolAccounts.length === 0) {
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
}
return ( return (
<div className={styles.adminSection}> <div className={styles.adminSection}>
<h3>Konten</h3> <h3>Konten</h3>
<div className={styles.accountsGrid}> <div className={styles.accountsGrid}>
{accounts.map((account) => ( {poolAccounts.map((account) => (
<div key={account.id} className={styles.accountCard}> <div key={account.id} className={styles.accountCard}>
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4> <h4>Mandanten-Konto</h4>
<div className={styles.accountInfo}> <div className={styles.accountInfo}>
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span> <span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span> <span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
</div> </div>
@ -631,6 +605,7 @@ export const BillingAdmin: React.FC = () => {
const [adminTab, setAdminTab] = useState<AdminTabType>( const [adminTab, setAdminTab] = useState<AdminTabType>(
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings' ['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
); );
const [subscriptionTabKey, setSubscriptionTabKey] = useState(0);
useEffect(() => { useEffect(() => {
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return; if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
@ -701,7 +676,7 @@ export const BillingAdmin: React.FC = () => {
padding: '8px 16px', padding: '8px 16px',
textDecoration: 'none', textDecoration: 'none',
borderRadius: '4px', borderRadius: '4px',
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent', backgroundColor: isActive ? 'var(--primary-color, #F25843)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)', color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
cursor: 'pointer', cursor: 'pointer',
@ -762,7 +737,7 @@ export const BillingAdmin: React.FC = () => {
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}> <button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
Guthaben Guthaben
</button> </button>
<button onClick={() => setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}> <button onClick={() => { setAdminTab('subscription'); setSubscriptionTabKey(k => k + 1); }} style={_tabStyle(adminTab === 'subscription')}>
Abonnement Abonnement
</button> </button>
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}> <button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
@ -782,9 +757,6 @@ export const BillingAdmin: React.FC = () => {
<> <>
{isSysAdmin && ( {isSysAdmin && (
<CreditAdder <CreditAdder
settings={settings}
accounts={accounts}
users={users}
onAddCredit={_handleAddCredit} onAddCredit={_handleAddCredit}
/> />
)} )}
@ -798,7 +770,7 @@ export const BillingAdmin: React.FC = () => {
)} )}
{adminTab === 'subscription' && ( {adminTab === 'subscription' && (
<SubscriptionTab mandateId={selectedMandateId} /> <SubscriptionTab key={subscriptionTabKey} mandateId={selectedMandateId} />
)} )}
{adminTab === 'transactions' && ( {adminTab === 'transactions' && (

View file

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

View file

@ -8,19 +8,16 @@
*/ */
import React, { useState, useEffect, useMemo, useCallback } from 'react'; 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 { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api'; import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import { useBilling, type BillingBalance } from '../../hooks/useBilling'; import { useBilling, type BillingBalance } from '../../hooks/useBilling';
import { createCheckoutSession, UserTransaction } from '../../api/billingApi'; import { UserTransaction } from '../../api/billingApi';
import { getUserDataCache } from '../../utils/userCache'; import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
// ============================================================================ // ============================================================================
// HELPER: Currency formatter // HELPER: Currency formatter
// ============================================================================ // ============================================================================
@ -46,34 +43,51 @@ interface ViewStatistics {
timeSeries: Array<{ date: string; cost: number; count: number }>; 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 // BALANCE CARD COMPONENT
// ============================================================================ // ============================================================================
interface BalanceCardProps { interface BalanceCardProps {
balance: BillingBalance; balance: BillingBalance;
onCheckout?: (mandateId: string, amount: number) => void; onOpenMandateAdmin?: (mandateId: string) => void;
checkoutLoading?: boolean;
} }
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => { const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
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';
return ( return (
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}> <div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
<div className={styles.balanceHeader}> <div className={styles.balanceHeader}>
<h3 className={styles.mandateName}>{balance.mandateName}</h3> {onOpenMandateAdmin ? (
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span> <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>
<div className={styles.balanceAmount}> <div className={styles.balanceAmount}>
{_formatCurrency(balance.balance)} {_formatCurrency(balance.balance)}
@ -83,60 +97,17 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
Niedriges Guthaben Niedriges Guthaben
</div> </div>
)} )}
{isMandatePrepaidPool && ( <p
<p style={{
style={{ marginTop: '12px',
marginTop: '12px', fontSize: '13px',
fontSize: '13px', lineHeight: 1.45,
lineHeight: 1.45, opacity: 0.75,
opacity: 0.75, marginBottom: 0,
marginBottom: 0, }}
}} >
> Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing).
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration Billing). </p>
</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>
)}
</div> </div>
); );
}; };
@ -157,7 +128,7 @@ const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
padding: '8px 16px', padding: '8px 16px',
textDecoration: 'none', textDecoration: 'none',
borderRadius: '4px', borderRadius: '4px',
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent', backgroundColor: isActive ? 'var(--primary-color, #F25843)' : 'transparent',
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)', color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
cursor: 'pointer', cursor: 'pointer',
@ -329,9 +300,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
export const BillingDataView: React.FC = () => { export const BillingDataView: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('overview'); const [activeTab, setActiveTab] = useState<TabType>('overview');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { request } = useApiRequest(); const navigate = useNavigate();
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); 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 // Scope filter: 'personal' | 'all' | mandateId
const [selectedScope, setSelectedScope] = useState<string>('personal'); const [selectedScope, setSelectedScope] = useState<string>('personal');
@ -399,58 +373,20 @@ export const BillingDataView: React.FC = () => {
setCheckoutMessage(null); setCheckoutMessage(null);
}, [searchParams, setSearchParams]); }, [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) // Statistics state (shared by Overview and Statistics tabs)
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null); const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
const [statsLoading, setStatsLoading] = useState(false); 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) // Transactions state (for Transactions tab)
const [transactions, setTransactions] = useState<UserTransaction[]>([]); const [transactions, setTransactions] = useState<UserTransaction[]>([]);
const [transactionsLoading, setTransactionsLoading] = useState(false); const [transactionsLoading, setTransactionsLoading] = useState(false);
const [transactionsError, setTransactionsError] = useState<string | null>(null); const [transactionsError, setTransactionsError] = useState<string | null>(null);
const [transactionsPagination, setTransactionsPagination] = useState<any>(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 // Load aggregated statistics from the view/statistics route
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => { const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
try { try {
@ -486,15 +422,47 @@ export const BillingDataView: React.FC = () => {
_loadViewStatistics(period, year, month); _loadViewStatistics(period, year, month);
}, [_loadViewStatistics]); }, [_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(() => { useEffect(() => {
if (activeTab === 'overview' || activeTab === 'statistics') { if (activeTab === 'overview' || activeTab === 'statistics') {
_loadViewStatistics('month', new Date().getFullYear()); _loadViewStatistics('month', new Date().getFullYear());
_loadStorageData();
} }
if (activeTab === 'overview') { }, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]);
_loadAllUserBalances();
}
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
// Load transactions with pagination support // Load transactions with pagination support
const _loadTransactions = useCallback(async (paginationParams?: any) => { const _loadTransactions = useCallback(async (paginationParams?: any) => {
@ -644,12 +612,6 @@ export const BillingDataView: React.FC = () => {
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all' const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
? balances ? balances
: balances.filter(b => b.mandateId === selectedScope); : 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 ( return (
<> <>
@ -666,35 +628,60 @@ export const BillingDataView: React.FC = () => {
<BalanceCard <BalanceCard
key={balance.mandateId} key={balance.mandateId}
balance={balance} balance={balance}
onCheckout={_handleCheckout} onOpenMandateAdmin={_openMandateBillingAdmin}
checkoutLoading={checkoutLoading}
/> />
))} ))}
</div> </div>
)} )}
</section> </section>
{/* All User Balance Cards (mandate/all scope) */} {/* Storage quick info */}
{filteredUserBalances.length > 0 && ( {!storageLoading && storageData.length > 0 && (
<section className={styles.section}> <section className={styles.section}>
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2> <h2 className={styles.sectionTitle}>Speicher</h2>
{allUserBalancesLoading ? ( <div className={styles.balanceGrid}>
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div> {storageData.map((sv) => {
) : ( const pct = sv.percentUsed ?? 0;
<div className={styles.balanceGrid}> const barColor = pct >= 90
{filteredUserBalances.map((ub, idx) => ( ? 'var(--color-error, #ef4444)'
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}> : pct >= 70
<div className={styles.balanceHeader}> ? 'var(--color-warning, #f59e0b)'
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3> : 'var(--primary-color, #F25843)';
<span className={styles.billingModel}>{ub.mandateName}</span> return (
</div> <div key={sv.mandateId} className={styles.balanceCard}>
<div className={styles.balanceAmount}> <h3 className={styles.mandateName}>{sv.mandateName}</h3>
{_formatCurrency(ub.balance || 0)} <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> </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> })}
)} </div>
</section> </section>
)} )}
@ -716,18 +703,104 @@ export const BillingDataView: React.FC = () => {
{/* Tab: Statistik (Dashboard) */} {/* Tab: Statistik (Dashboard) */}
{/* ================================================================ */} {/* ================================================================ */}
{activeTab === 'statistics' && ( {activeTab === 'statistics' && (
<section className={styles.section}> <>
<FormGeneratorReport {/* Storage volume section */}
title="Nutzungsstatistik" <section className={styles.section}>
subtitle="Detaillierte Analyse der AI-Nutzung" <div className={styles.statisticsChart}>
periodSelector={periodSelectorConfig} <h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
onFilterChange={_handleStatsFilterChange} Speicherverbrauch
loading={statsLoading} </h3>
sections={statisticsSections} {storageLoading ? (
noDataMessage="Keine Statistiken verfügbar" <div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
currencyCode="CHF" ) : storageData.length === 0 ? (
/> <div className={styles.noData}>Keine Speicherdaten verfügbar</div>
</section> ) : (
<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); }).format(amount);
}; };
const getBillingModelLabel = (model: string) => {
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
return 'Prepaid (Mandant)';
};
return ( return (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table className={styles.transactionsTable}> <table className={styles.transactionsTable}>
<thead> <thead>
<tr> <tr>
<th>Mandant</th> <th>Mandant</th>
<th>Billing-Modell</th>
<th>Anzahl Benutzer</th> <th>Anzahl Benutzer</th>
<th>Standard-Guthaben</th> <th>Warnschwelle (%)</th>
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th> <th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
<th>Aktion</th> <th>Aktion</th>
</tr> </tr>
@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''} className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
> >
<td>{balance.mandateName || balance.mandateId}</td> <td>{balance.mandateName || balance.mandateId}</td>
<td>{getBillingModelLabel(balance.billingModel)}</td>
<td>{balance.userCount}</td> <td>{balance.userCount}</td>
<td>{formatCurrency(balance.defaultUserCredit)}</td> <td>{balance.warningThresholdPercent}%</td>
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td> <td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
<td> <td>
<button <button

View file

@ -12,6 +12,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from '../../hooks/useSubscription';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi'; import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import styles from './Billing.module.css'; import styles from './Billing.module.css';
// ============================================================================ // ============================================================================
@ -43,7 +44,7 @@ const _statusLabel: Record<string, { label: string; color: string }> = {
PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' }, PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' },
SCHEDULED: { label: 'Geplant', color: '#8b5cf6' }, SCHEDULED: { label: 'Geplant', color: '#8b5cf6' },
ACTIVE: { label: 'Aktiv', color: '#22c55e' }, ACTIVE: { label: 'Aktiv', color: '#22c55e' },
TRIALING: { label: 'Testphase', color: '#3b82f6' }, TRIALING: { label: 'Testphase', color: '#38bdf8' },
PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' }, PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' },
EXPIRED: { label: 'Abgelaufen', color: '#6b7280' }, EXPIRED: { label: 'Abgelaufen', color: '#6b7280' },
}; };
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
NONE: '—', NONE: '—',
}; };
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
const storageOverageChfPerGbMonth = 0.5;
// ============================================================================ // ============================================================================
// Plan Card // Plan Card
// ============================================================================ // ============================================================================
@ -71,13 +75,13 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
return ( return (
<div style={{ <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', borderRadius: '8px',
padding: '1.25rem', padding: '1.25rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '0.75rem', 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, minWidth: 220,
}}> }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -85,12 +89,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
{isCurrent && ( {isCurrent && (
<span style={{ <span style={{
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px', 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> }}>Aktuell</span>
)} )}
</div> </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)} {_t(plan.description)}
</p> </p>
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
<div style={{ fontSize: '0.85rem' }}> <div style={{ fontSize: '0.85rem' }}>
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div> <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>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> </div>
)} )}
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
{plan.trialDays} Tage kostenlos {plan.trialDays} Tage kostenlos
{plan.maxUsers && <> · max. {plan.maxUsers} User</>} {plan.maxUsers && <> · max. {plan.maxUsers} User</>}
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>} {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> </div>
)} )}
@ -115,7 +140,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
disabled={!!activatingPlanKey} disabled={!!activatingPlanKey}
style={{ style={{
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none', 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', cursor: activatingPlanKey ? 'wait' : 'pointer',
opacity: activatingPlanKey ? 0.6 : 1, opacity: activatingPlanKey ? 0.6 : 1,
}} }}
@ -152,15 +177,15 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
return ( return (
<div style={{ <div style={{
border: '1px solid var(--color-border, #333)', border: '1px solid var(--color-border, var(--border-color, #333))',
borderRadius: '8px', borderRadius: '8px',
padding: '1.25rem', padding: '1.25rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '0.5rem', 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} {label}
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <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 && ( {!isPending && !isScheduled && (
<div style={{ <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', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
}}> }}>
<span>Gestartet: {_formatDate(sub.startedAt)}</span> <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 && ( {isActive && !sub.recurring && sub.currentPeriodEnd && (
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span> <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> </div>
)} )}
@ -224,7 +263,7 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
disabled={reactivating} disabled={reactivating}
style={{ style={{
padding: '6px 14px', borderRadius: '6px', border: 'none', 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', fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
}} }}
> >
@ -311,14 +350,21 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
if (sessionId && !verifyCalledRef.current) { if (sessionId && !verifyCalledRef.current) {
verifyCalledRef.current = true; verifyCalledRef.current = true;
verifyCheckout(sessionId) const _pollUntilActive = async (retries = 5, delayMs = 2000) => {
.then((result) => { try {
const result = await verifyCheckout(sessionId);
if (result.status === 'activated') { if (result.status === 'activated') {
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' }); setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
setJustPaid(false); setJustPaid(false);
return;
} }
}) } catch { /* handled below via retry */ }
.catch(() => {}); if (retries > 0) {
await new Promise(r => setTimeout(r, delayMs));
await _pollUntilActive(retries - 1, delayMs);
}
};
_pollUntilActive();
} }
} else if (params.get('canceled') === 'true') { } else if (params.get('canceled') === 'true') {
setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' }); 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', title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen', confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined, cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
variant: 'danger', variant: 'danger',
}, },
); );
@ -397,9 +443,9 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
{checkoutMessage && ( {checkoutMessage && (
<div style={{ <div style={{
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px', marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(59,130,246,0.12)', 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' : '#3b82f6'}`, border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-color, #F25843)'}`,
color: checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6', color: checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-light, #F25843)',
fontSize: '0.9rem', fontSize: '0.9rem',
}}> }}>
{checkoutMessage.text} {checkoutMessage.text}

View file

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

View file

@ -337,7 +337,7 @@ const OutputCard: React.FC<{
run: CompletedRun; run: CompletedRun;
instanceId?: string; instanceId?: string;
}> = ({ run }) => { }> = ({ run }) => {
const ts = run._modifiedAt ?? run._createdAt ?? 0; const ts = run.sysModifiedAt ?? run.sysCreatedAt ?? 0;
const files: Array<{ name: string; fileId: string }> = []; const files: Array<{ name: string; fileId: string }> = [];
const nodeOutputs = run.nodeOutputs ?? {}; const nodeOutputs = run.nodeOutputs ?? {};
for (const [, out] of Object.entries(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 { .dossier {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 140px); flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
} }
@ -357,6 +406,115 @@
.typingDots { animation: blink 1.4s infinite both; } .typingDots { animation: blink 1.4s infinite both; }
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } } @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 */ /* Input Area */
.inputArea { .inputArea {
display: flex; display: flex;

View file

@ -1,69 +1,125 @@
/** /**
* CommCoach Dossier View (Main View) * 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. * Voice first, always with text fallback.
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
*/ */
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useCommcoach } from '../../../hooks/useCommcoach'; import { useCommcoach } from '../../../hooks/useCommcoach';
import { type TtsEvent } from '../../../hooks/useTtsPlayback'; import { type TtsEvent } from '../../../hooks/useTtsPlayback';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
import { import {
getDossierExportUrl, getSessionExportUrl, getDossierExportUrl, getSessionExportUrl,
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
getScoreHistoryApi, getPersonasApi, getScoreHistoryApi, getPersonasApi,
type CoachingDocument, type CoachingPersona, type CoachingPersona,
type SendMessageOptions,
} from '../../../api/commcoachApi'; } from '../../../api/commcoachApi';
import api from '../../../api';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; 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 styles from './CommcoachDossierView.module.css';
import { useVoiceController } from './useVoiceController'; 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 = () => { type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
const coach = useCommcoach();
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 { request } = useApiRequest();
const instanceId = useInstanceId();
const [activeTab, setActiveTab] = useState<TabKey>('coaching'); const [activeTab, setActiveTab] = useState<TabKey>('coaching');
const [showNewContext, setShowNewContext] = useState(false); const [showNewContext, setShowNewContext] = useState(false);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState(''); const [newDescription, setNewDescription] = useState('');
const [newCategory, setNewCategory] = useState('custom'); const [newCategory, setNewCategory] = useState('custom');
const [udbCollapsed, setUdbCollapsed] = useState(false);
const [udbTab, setUdbTab] = useState<UdbTab>('files');
const [newTaskTitle, setNewTaskTitle] = useState(''); 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 [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
const [personas, setPersonas] = useState<CoachingPersona[]>([]); const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined); 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 inputRef = useRef<HTMLTextAreaElement>(null);
const sendMessageRef = useRef(coach.sendMessage); const sendMessageRef = useRef(coach.sendMessage);
sendMessageRef.current = coach.sendMessage; sendMessageRef.current = coach.sendMessage;
const voice = useVoiceController({ const attachedFileIdsRef = useRef(attachedFileIds);
onFinalText: (text) => sendMessageRef.current(text), 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 voice = useVoiceController({
const debugLogsRef = useRef<string[]>([]); onFinalText: (text) => {
const [debugVisible, setDebugVisible] = useState(false); const opts: SendMessageOptions = {};
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]); if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
const _dlog = useCallback((tag: string, info?: string) => { if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
const t = new Date(); if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2,'0')}.${String(t.getMilliseconds()).padStart(3,'0')}`; const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`; if (allowed) opts.allowedProviders = allowed;
debugLogsRef.current.push(entry); sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
if (debugLogsRef.current.length > 80) debugLogsRef.current.shift(); },
}, []); });
useEffect(() => { (window as any).__dlog = _dlog; return () => { delete (window as any).__dlog; }; }, [_dlog]);
// #endregion
useEffect(() => { useEffect(() => {
coach.onTtsEventRef.current = (event: TtsEvent) => { coach.onTtsEventRef.current = (event: TtsEvent) => {
@ -82,27 +138,14 @@ export const CommcoachDossierView: React.FC = () => {
} }
}, [coach.contexts, coach.selectedContextId, coach.selectContext]); }, [coach.contexts, coach.selectedContextId, coach.selectContext]);
// Load documents, scores, personas when context changes // Load scores, personas when context changes
useEffect(() => { useEffect(() => {
if (!instanceId || !coach.selectedContextId) return; if (!instanceId || !coach.selectedContextId) return;
getDocumentsApi(request, instanceId, coach.selectedContextId)
.then(d => setDocuments(d))
.catch(() => {});
getScoreHistoryApi(request, instanceId, coach.selectedContextId) getScoreHistoryApi(request, instanceId, coach.selectedContextId)
.then(h => setScoreHistory(h)) .then(h => setScoreHistory(h))
.catch(() => {}); .catch(() => {});
}, [instanceId, request, coach.selectedContextId]); }, [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(() => { useEffect(() => {
if (!instanceId) return; if (!instanceId) return;
getPersonasApi(request, instanceId) getPersonasApi(request, instanceId)
@ -110,6 +153,23 @@ export const CommcoachDossierView: React.FC = () => {
.catch(() => {}); .catch(() => {});
}, [instanceId, request]); }, [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(() => { useEffect(() => {
if (activeTab !== 'coaching' || !coach.session) { if (activeTab !== 'coaching' || !coach.session) {
voice.deactivate(); voice.deactivate();
@ -118,14 +178,51 @@ export const CommcoachDossierView: React.FC = () => {
} }
}, [activeTab, coach.session?.id, voice]); }, [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 handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]); const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return; if (!coach.inputValue.trim() || coach.isStreaming) return;
await coach.sendMessage(coach.inputValue); const opts: SendMessageOptions = {};
}, [coach]); 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) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
@ -144,46 +241,6 @@ export const CommcoachDossierView: React.FC = () => {
coach.selectContext(contextId, { skipSessionResume: true }); coach.selectContext(contextId, { skipSessionResume: true });
}, [coach]); }, [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 () => { const handleAddTask = useCallback(async () => {
if (!newTaskTitle.trim()) return; if (!newTaskTitle.trim()) return;
await coach.addTask(newTaskTitle); await coach.addTask(newTaskTitle);
@ -195,7 +252,30 @@ export const CommcoachDossierView: React.FC = () => {
} }
return ( 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 */} {/* Context Selector */}
<div className={styles.contextSelector}> <div className={styles.contextSelector}>
{coach.contexts.map(ctx => ( {coach.contexts.map(ctx => (
@ -286,13 +366,13 @@ export const CommcoachDossierView: React.FC = () => {
{/* Tab Navigation */} {/* Tab Navigation */}
<div className={styles.tabs}> <div className={styles.tabs}>
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => ( {(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
<button <button
key={tab} key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`} className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
> >
{_tabLabel(tab, coach, documents)} {_tabLabel(tab, coach)}
</button> </button>
))} ))}
</div> </div>
@ -394,6 +474,63 @@ export const CommcoachDossierView: React.FC = () => {
</div> </div>
</AutoScroll> </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 */} {/* Input Area */}
<div className={styles.inputArea}> <div className={styles.inputArea}>
<div className={styles.voiceStatus}> <div className={styles.voiceStatus}>
@ -411,6 +548,53 @@ export const CommcoachDossierView: React.FC = () => {
: 'Mikrofon wird gestartet...'} : 'Mikrofon wird gestartet...'}
</span> </span>
</div> </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}> <div className={styles.textInputRow}>
<textarea <textarea
ref={inputRef} ref={inputRef}
@ -422,6 +606,153 @@ export const CommcoachDossierView: React.FC = () => {
rows={1} rows={1}
disabled={coach.isStreaming} 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}> <button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
Senden Senden
</button> </button>
@ -546,54 +877,10 @@ export const CommcoachDossierView: React.FC = () => {
</div> </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 </div>
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] || '*'; return icons[category] || '*';
} }
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string { function _tabLabel(tab: TabKey, coach: any): string {
switch (tab) { switch (tab) {
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching'; case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
case 'tasks': return `Aufgaben (${coach.tasks.length})`; case 'tasks': return `Aufgaben (${coach.tasks.length})`;
case 'sessions': return `Sessions (${coach.sessions.length})`; case 'sessions': return `Sessions (${coach.sessions.length})`;
case 'scores': return `Bewertungen (${coach.scores.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); 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 { function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit', empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
@ -649,4 +929,14 @@ function _dimensionLabel(dim: string): string {
return labels[dim] || dim; 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; 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 * 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 React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { import {
getProfileApi, updateProfileApi, getProfileApi, updateProfileApi,
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
type CoachingUserProfile, type CoachingUserProfile,
} from '../../../api/commcoachApi'; } from '../../../api/commcoachApi';
import styles from './CommcoachSettingsView.module.css'; import styles from './CommcoachSettingsView.module.css';
@ -19,16 +20,11 @@ export const CommcoachSettingsView: React.FC = () => {
const instanceId = useInstanceId(); const instanceId = useInstanceId();
const [profile, setProfile] = useState<CoachingUserProfile | null>(null); const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
const [languages, setLanguages] = useState<any[]>([]);
const [voices, setVoices] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = 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 [reminderEnabled, setReminderEnabled] = useState(false);
const [reminderTime, setReminderTime] = useState('09:00'); const [reminderTime, setReminderTime] = useState('09:00');
const [emailEnabled, setEmailEnabled] = useState(true); const [emailEnabled, setEmailEnabled] = useState(true);
@ -38,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => {
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const [profileData, languagesData] = await Promise.all([ const profileData = await getProfileApi(request, instanceId);
getProfileApi(request, instanceId),
getVoiceLanguagesApi(request, instanceId),
]);
setProfile(profileData); setProfile(profileData);
setLanguages(languagesData || []);
if (profileData) { if (profileData) {
setLanguage(profileData.preferredLanguage || 'de-DE');
setVoiceId(profileData.preferredVoice || '');
setReminderEnabled(profileData.dailyReminderEnabled || false); setReminderEnabled(profileData.dailyReminderEnabled || false);
setReminderTime(profileData.dailyReminderTime || '09:00'); setReminderTime(profileData.dailyReminderTime || '09:00');
setEmailEnabled(profileData.emailSummaryEnabled !== false); setEmailEnabled(profileData.emailSummaryEnabled !== false);
} }
const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE');
setVoices(voicesData || []);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Fehler beim Laden'); setError(err.message || 'Fehler beim Laden');
} finally { } finally {
@ -64,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => {
loadData(); loadData();
}, [request, instanceId]); }, [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 () => { const handleSave = useCallback(async () => {
if (!instanceId) return; if (!instanceId) return;
setSaving(true); setSaving(true);
@ -81,8 +57,6 @@ export const CommcoachSettingsView: React.FC = () => {
setSuccess(null); setSuccess(null);
try { try {
const updated = await updateProfileApi(request, instanceId, { const updated = await updateProfileApi(request, instanceId, {
preferredLanguage: language,
preferredVoice: voiceId || null,
dailyReminderEnabled: reminderEnabled, dailyReminderEnabled: reminderEnabled,
dailyReminderTime: reminderTime, dailyReminderTime: reminderTime,
emailSummaryEnabled: emailEnabled, emailSummaryEnabled: emailEnabled,
@ -95,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => {
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]); }, [request, instanceId, 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]);
if (loading) { if (loading) {
return <div className={styles.loading}>Einstellungen werden geladen...</div>; 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>} {error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>} {success && <div className={styles.success}>{success}</div>}
{/* Voice Settings */}
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}>Sprache und Stimme</h3> <h3 className={styles.sectionTitle}>Stimme & Sprache</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
<div className={styles.field}> Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.
<label className={styles.label}>Sprache</label> </p>
<select className={styles.select} value={language} onChange={e => handleLanguageChange(e.target.value)}> <Link to="/settings" onClick={() => {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
{languages.length > 0 ? ( Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")
languages.map((lang: any) => ( </Link>
<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>
</div> </div>
{/* Reminder Settings */}
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}>Erinnerungen</h3> <h3 className={styles.sectionTitle}>Erinnerungen</h3>
<div className={styles.field}> <div className={styles.field}>
<label className={styles.checkboxLabel}> <label className={styles.checkboxLabel}>
<input <input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
type="checkbox"
checked={reminderEnabled}
onChange={e => setReminderEnabled(e.target.checked)}
/>
Taegliche Coaching-Erinnerung per E-Mail Taegliche Coaching-Erinnerung per E-Mail
</label> </label>
</div> </div>
{reminderEnabled && ( {reminderEnabled && (
<div className={styles.field}> <div className={styles.field}>
<label className={styles.label}>Uhrzeit</label> <label className={styles.label}>Uhrzeit</label>
<input <input type="time" className={styles.input} value={reminderTime} onChange={e => setReminderTime(e.target.value)} />
type="time"
className={styles.input}
value={reminderTime}
onChange={e => setReminderTime(e.target.value)}
/>
</div> </div>
)} )}
<div className={styles.field}> <div className={styles.field}>
<label className={styles.checkboxLabel}> <label className={styles.checkboxLabel}>
<input <input type="checkbox" checked={emailEnabled} onChange={e => setEmailEnabled(e.target.checked)} />
type="checkbox"
checked={emailEnabled}
onChange={e => setEmailEnabled(e.target.checked)}
/>
Session-Zusammenfassung per E-Mail senden Session-Zusammenfassung per E-Mail senden
</label> </label>
</div> </div>
</div> </div>
{/* Stats */}
{profile && ( {profile && (
<div className={styles.section}> <div className={styles.section}>
<h3 className={styles.sectionTitle}>Statistik</h3> <h3 className={styles.sectionTitle}>Statistik</h3>
<div className={styles.statsGrid}> <div className={styles.statsGrid}>
<div className={styles.statItem}> <div className={styles.statItem}><span className={styles.statValue}>{profile.totalSessions}</span><span className={styles.statLabel}>Sessions gesamt</span></div>
<span className={styles.statValue}>{profile.totalSessions}</span> <div className={styles.statItem}><span className={styles.statValue}>{profile.totalMinutes}</span><span className={styles.statLabel}>Minuten gesamt</span></div>
<span className={styles.statLabel}>Sessions gesamt</span> <div className={styles.statItem}><span className={styles.statValue}>{profile.streakDays}</span><span className={styles.statLabel}>Aktueller Streak</span></div>
</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.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>
</div> </div>
)} )}

View file

@ -6,10 +6,12 @@
* *
* Uses the generic useVoiceStream hook for mic capture + STT streaming. * Uses the generic useVoiceStream hook for mic capture + STT streaming.
* Google Streaming STT handles silence detection natively. * 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 { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import api from '../../../api';
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted'; export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
@ -22,6 +24,7 @@ export interface VoiceControllerApi {
ttsPlaying: () => void; ttsPlaying: () => void;
ttsPaused: () => void; ttsPaused: () => void;
ttsEnded: () => void; ttsEnded: () => void;
ttsStopped: () => void;
toggleMute: () => void; toggleMute: () => void;
} }
@ -30,6 +33,8 @@ export interface VoiceControllerCallbacks {
onInterimText?: (text: string) => void; onInterimText?: (text: string) => void;
} }
const _DEFAULT_STT_LANGUAGE = 'de-DE';
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi { export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
const [state, setState] = useState<VoiceState>('idle'); const [state, setState] = useState<VoiceState>('idle');
const [muted, setMuted] = useState(false); const [muted, setMuted] = useState(false);
@ -38,6 +43,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
const cbRef = useRef(callbacks); const cbRef = useRef(callbacks);
cbRef.current = 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 _dlog = useCallback((tag: string, info?: string) => {
const t = new Date(); const t = new Date();
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`; 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)), onError: (err) => _dlog('VOICE-ERR', String(err)),
}); });
const _startStream = useCallback(() => {
return voiceStream.start(sttLanguageRef.current);
}, [voiceStream]);
const activate = useCallback(async () => { const activate = useCallback(async () => {
if (stateRef.current !== 'idle') return; if (stateRef.current !== 'idle') return;
_setState('listening'); _setState('listening');
try { try {
await voiceStream.start('de-DE'); await _startStream();
} catch (err) { } catch (err) {
_dlog('MIC-ERR', String(err)); _dlog('MIC-ERR', String(err));
_setState('idle'); _setState('idle');
} }
}, [_setState, voiceStream, _dlog]); }, [_setState, _startStream, _dlog]);
const deactivate = useCallback(() => { const deactivate = useCallback(() => {
voiceStream.stop(); voiceStream.stop();
@ -94,15 +115,27 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
const ttsPaused = useCallback(() => { const ttsPaused = useCallback(() => {
if (stateRef.current !== 'botSpeaking') return; if (stateRef.current !== 'botSpeaking') return;
_setState('interrupted'); _setState('interrupted');
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); _startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, voiceStream, _dlog]); }, [_setState, _startStream, _dlog]);
const ttsEnded = useCallback(() => { const ttsEnded = useCallback(() => {
const cur = stateRef.current; const cur = stateRef.current;
if (cur !== 'botSpeaking' && cur !== 'interrupted') return; if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
_setState('listening'); _setState('listening');
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); _startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, voiceStream, _dlog]); }, [_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 toggleMute = useCallback(() => {
const cur = stateRef.current; const cur = stateRef.current;
@ -110,13 +143,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
if (mutedRef.current) { if (mutedRef.current) {
_setMuted(false); _setMuted(false);
if (cur === 'listening' || cur === 'interrupted') { 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 { } else {
_setMuted(true); _setMuted(true);
voiceStream.stop(); voiceStream.stop();
} }
}, [_setMuted, voiceStream, _dlog]); }, [_setMuted, _startStream, voiceStream, _dlog]);
return { return {
state, state,
@ -127,6 +160,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
ttsPlaying, ttsPlaying,
ttsPaused, ttsPaused,
ttsEnded, ttsEnded,
ttsStopped,
toggleMute, toggleMute,
}; };
} }

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