Merge pull request #25 from valueonag/feat/unified-data-bar

Feat/unified data bar
This commit is contained in:
Patrick Motsch 2026-03-30 23:34:01 +02:00 committed by GitHub
commit 6a66388def
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
109 changed files with 5050 additions and 2736 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

@ -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;
@ -494,27 +480,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 +500,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

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

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

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

@ -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 #1976d2', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#1976d2',
}}>
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: '#1976d2' }}
title="Upload files"
>
{uploading ? '...' : '+'}
</button>
<button
onClick={_refreshAll}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{'\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;
}

View file

@ -1,20 +1,48 @@
/** /**
* DataSourcePanel -- Browse external data sources as a lazy-loading tree. * SourcesTab Full data-source management inside the Unified Data Bar.
* *
* Tree structure: * Tree structure (Browse Sources):
* UserConnection (Level 1, loaded on mount) * UserConnection (Level 1, loaded on mount)
* Service (Level 2, loaded when connection expanded) * Service (Level 2, loaded when connection expanded)
* Folder / Site / File (Level 3+, loaded when service/folder expanded) * Folder / Site / File (Level 3+, loaded when service/folder expanded)
* *
* Each folder node can be added as a DataSource for this workspace instance. * Feature Data tree:
* MandateGroup
* FeatureConnection (feature instance)
* FeatureTable (tables exposed by that instance)
*
* Active Sources sections show scope-cycling and neutralize-toggle buttons.
*/ */
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import api from '../../../api'; import type { UdbContext } from './UnifiedDataBar';
import { getPageIcon } from '../../../config/pageRegistry'; import api from '../../api';
import type { DataSource, FeatureDataSource } from './useWorkspace'; import { getPageIcon } from '../../config/pageRegistry';
import styles from './SourcesTab.module.css';
/* ─── Types ─────────────────────────────────────────────────────────── */ /* ─── Types (inline, no external imports) ────────────────────────────── */
interface UdbDataSource {
id: string;
connectionId: string;
sourceType: string;
path: string;
label: string;
displayPath?: string;
scope: string;
neutralize: boolean;
}
interface UdbFeatureDataSource {
id: string;
featureInstanceId: string;
featureCode: string;
tableName: string;
objectKey: string;
label: string;
scope: string;
neutralize: boolean;
}
interface TreeNode { interface TreeNode {
key: string; key: string;
@ -27,7 +55,6 @@ interface TreeNode {
connectionId: string; connectionId: string;
service?: string; service?: string;
path?: string; path?: string;
/** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */
displayPath?: string; displayPath?: string;
authority?: string; authority?: string;
} }
@ -58,15 +85,13 @@ interface FeatureTableNode {
fields: string[]; fields: string[];
} }
interface DataSourcePanelProps { /* ─── Props ──────────────────────────────────────────────────────────── */
instanceId: string;
dataSources: DataSource[]; interface SourcesTabProps {
featureDataSources: FeatureDataSource[]; context: UdbContext;
onRefresh: () => void;
onRefreshFeatureDataSources: () => void;
} }
/* ─── Icons ─────────────────────────────────────────────────────────── */ /* ─── Icons ─────────────────────────────────────────────────────────── */
const _AUTHORITY_ICONS: Record<string, string> = { const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6', msft: '\uD83D\uDFE6',
@ -113,6 +138,40 @@ function _getSourceIcon(sourceType: string): string {
return map[sourceType] || '\uD83D\uDCC1'; return map[sourceType] || '\uD83D\uDCC1';
} }
/* ─── Scope / Neutralize constants ───────────────────────────────────── */
const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate'];
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
const _SCOPE_LABELS: Record<string, string> = {
personal: 'Personal',
featureInstance: 'Feature Instance',
mandate: 'Mandate',
global: 'Global',
};
function _nextScope(current: string): string {
const idx = _SCOPE_ORDER.indexOf(current);
if (idx === -1) return _SCOPE_ORDER[0];
return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length];
}
/* ─── Tree helpers ───────────────────────────────────────────────────── */
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
return nodes.map(n => {
if (n.key === key) return updater(n);
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
return n;
});
}
function _mapFeatureTreeUpdate( function _mapFeatureTreeUpdate(
prev: MandateGroupNode[], prev: MandateGroupNode[],
featureInstanceId: string, featureInstanceId: string,
@ -137,14 +196,14 @@ function _findFeatureInstanceMeta(
return null; return null;
} }
function _personalDataSourceHoverTitle(connLabel: string, ds: DataSource): string { function _personalDataSourceHoverTitle(connLabel: string, ds: UdbDataSource): string {
const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || '';
return pathPart ? `${connLabel} / ${pathPart}` : connLabel; return pathPart ? `${connLabel} / ${pathPart}` : connLabel;
} }
function _featureDataSourceHoverTitle( function _featureDataSourceHoverTitle(
meta: { mandateLabel: string; instanceLabel: string } | null, meta: { mandateLabel: string; instanceLabel: string } | null,
fds: FeatureDataSource, fds: UdbFeatureDataSource,
): string { ): string {
const parts: string[] = []; const parts: string[] = [];
if (meta) { if (meta) {
@ -160,24 +219,153 @@ function _featureDataSourceHoverTitle(
return parts.join(' / '); return parts.join(' / ');
} }
/* ─── Component ─────────────────────────────────────────────────────── */ /* ─── Data fetching (module-level) ───────────────────────────────────── */
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({ async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
instanceId, const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
dataSources, const services = res.data.services || [];
featureDataSources, return services.map((s: any) => ({
onRefresh, key: `svc-${connectionId}-${s.service}`,
onRefreshFeatureDataSources, label: s.label || s.service,
}) => { icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
type: 'service' as const,
expanded: false,
loading: false,
children: null,
connectionId,
service: s.service,
path: '/',
displayPath: s.label || s.service,
}));
}
async function _browseService(
instanceId: string,
connectionId: string,
service: string,
path: string,
parentDisplayPath: string | undefined,
): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
params: { service, path },
});
const items = res.data.items || [];
return items.map((entry: any, idx: number) => {
const seg = entry.name || '';
const displayPath = parentDisplayPath
? `${parentDisplayPath} / ${seg}`
: seg;
return {
key: `item-${connectionId}-${service}-${entry.path || idx}`,
label: entry.name,
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
type: entry.isFolder ? 'folder' as const : 'file' as const,
expanded: false,
loading: false,
children: entry.isFolder ? null : [],
connectionId,
service,
path: entry.path,
displayPath,
};
});
}
function _fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
};
return map[ext] || '\uD83D\uDCC4';
}
/* ─── Spinner (inline) ───────────────────────────────────────────────── */
function _Spinner(): React.ReactElement {
return (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Component ──────────────────────────────────────────────────────── */
const SourcesTab: React.FC<SourcesTabProps> = ({ context }) => {
const instanceId = context.instanceId;
/* ── Active sources (fetched internally) ── */
const [dataSources, setDataSources] = useState<UdbDataSource[]>([]);
const [featureDataSources, setFeatureDataSources] = useState<UdbFeatureDataSource[]>([]);
/* ── Browse tree state ── */
const [tree, setTree] = useState<TreeNode[]>([]); const [tree, setTree] = useState<TreeNode[]>([]);
const [loadingRoot, setLoadingRoot] = useState(false); const [loadingRoot, setLoadingRoot] = useState(false);
const [addingPath, setAddingPath] = useState<string | null>(null); const [addingPath, setAddingPath] = useState<string | null>(null);
/* ── Feature tree state ── */
const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]); const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
const [loadingFeatures, setLoadingFeatures] = useState(false); const [loadingFeatures, setLoadingFeatures] = useState(false);
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null); const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
const mountedRef = useRef(true); const mountedRef = useRef(true);
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
/* ── Fetch active personal data sources ── */
const _fetchDataSources = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/datasources`)
.then(res => {
if (!mountedRef.current) return;
const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({
id: d.id,
connectionId: d.connectionId,
sourceType: d.sourceType,
path: d.path,
label: d.label,
displayPath: d.displayPath,
scope: d.scope || 'personal',
neutralize: d.neutralize ?? false,
}));
setDataSources(list);
})
.catch(() => { if (mountedRef.current) setDataSources([]); });
}, [instanceId]);
/* ── Fetch active feature data sources ── */
const _fetchFeatureDataSources = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/feature-datasources`)
.then(res => {
if (!mountedRef.current) return;
const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({
id: d.id,
featureInstanceId: d.featureInstanceId,
featureCode: d.featureCode,
tableName: d.tableName,
objectKey: d.objectKey,
label: d.label,
scope: d.scope || 'personal',
neutralize: d.neutralize ?? false,
}));
setFeatureDataSources(list);
})
.catch(() => { if (mountedRef.current) setFeatureDataSources([]); });
}, [instanceId]);
useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]);
useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]);
/* ── Load Level 1: UserConnections ── */ /* ── Load Level 1: UserConnections ── */
const _loadConnections = useCallback(() => { const _loadConnections = useCallback(() => {
if (!instanceId) return; if (!instanceId) return;
@ -271,23 +459,23 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
label: node.label, label: node.label,
displayPath: node.displayPath || node.label, displayPath: node.displayPath || node.label,
}); });
onRefresh(); _fetchDataSources();
} catch (err) { } catch (err) {
console.error('Failed to add data source:', err); console.error('Failed to add data source:', err);
} finally { } finally {
if (mountedRef.current) setAddingPath(null); if (mountedRef.current) setAddingPath(null);
} }
}, [instanceId, onRefresh]); }, [instanceId, _fetchDataSources]);
/* ── Remove DataSource ── */ /* ── Remove DataSource ── */
const _removeDatasource = useCallback(async (dsId: string) => { const _removeDatasource = useCallback(async (dsId: string) => {
try { try {
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
onRefresh(); _fetchDataSources();
} catch (err) { } catch (err) {
console.error('Failed to remove data source:', err); console.error('Failed to remove data source:', err);
} }
}, [instanceId, onRefresh]); }, [instanceId, _fetchDataSources]);
/* ── Check if a path is already added ── */ /* ── Check if a path is already added ── */
const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => { const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
@ -296,6 +484,50 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
); );
}, [dataSources]); }, [dataSources]);
/* ── Scope change (personal data source, optimistic) ── */
const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => {
const newScope = _nextScope(ds.scope);
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d));
try {
await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope });
} catch {
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d));
}
}, []);
/* ── Neutralize toggle (personal data source, optimistic) ── */
const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => {
const newValue = !ds.neutralize;
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d));
try {
await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue });
} catch {
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d));
}
}, []);
/* ── Scope change (feature data source, optimistic) ── */
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
const newScope = _nextScope(fds.scope);
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d));
try {
await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope });
} catch {
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d));
}
}, []);
/* ── Neutralize toggle (feature data source, optimistic) ── */
const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => {
const newValue = !fds.neutralize;
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d));
try {
await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue });
} catch {
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d));
}
}, []);
/* ── Feature Connections: Load Level 1 ── */ /* ── Feature Connections: Load Level 1 ── */
const _loadFeatureConnections = useCallback(() => { const _loadFeatureConnections = useCallback(() => {
if (!instanceId) return; if (!instanceId) return;
@ -384,23 +616,23 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
objectKey: table.objectKey, objectKey: table.objectKey,
label: table.label?.en || table.label?.de || table.tableName, label: table.label?.en || table.label?.de || table.tableName,
}); });
onRefreshFeatureDataSources(); _fetchFeatureDataSources();
} catch (err) { } catch (err) {
console.error('Failed to add feature data source:', err); console.error('Failed to add feature data source:', err);
} finally { } finally {
if (mountedRef.current) setAddingFeatureKey(null); if (mountedRef.current) setAddingFeatureKey(null);
} }
}, [instanceId, onRefreshFeatureDataSources]); }, [instanceId, _fetchFeatureDataSources]);
/* ── Feature: Remove FeatureDataSource ── */ /* ── Feature: Remove FeatureDataSource ── */
const _removeFeatureDataSource = useCallback(async (fdsId: string) => { const _removeFeatureDataSource = useCallback(async (fdsId: string) => {
try { try {
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
onRefreshFeatureDataSources(); _fetchFeatureDataSources();
} catch (err) { } catch (err) {
console.error('Failed to remove feature data source:', err); console.error('Failed to remove feature data source:', err);
} }
}, [instanceId, onRefreshFeatureDataSources]); }, [instanceId, _fetchFeatureDataSources]);
/* ── Feature: check if table already added ── */ /* ── Feature: check if table already added ── */
const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
@ -409,9 +641,11 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
); );
}, [featureDataSources]); }, [featureDataSources]);
/* ── Render ── */
return ( return (
<div style={{ padding: 8, fontSize: 13 }}> <div className={styles.sourcesTab} style={{ padding: 8, fontSize: 13 }}>
{/* Active DataSources */} {/* ── Active Personal Sources ── */}
{dataSources.length > 0 && ( {dataSources.length > 0 && (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}> <div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
@ -434,6 +668,27 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{connLabel} {folder} {connLabel} {folder}
</span> </span>
<button
onClick={() => _cyclePersonalScope(ds)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', lineHeight: 1,
}}
title={`Scope: ${_SCOPE_LABELS[ds.scope] || ds.scope}${_SCOPE_LABELS[_nextScope(ds.scope)]}`}
>
{_SCOPE_ICONS[ds.scope] || _SCOPE_ICONS.personal}
</button>
<button
onClick={() => _togglePersonalNeutralize(ds)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', lineHeight: 1,
opacity: ds.neutralize ? 1 : 0.35,
}}
title={ds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
>
{'\uD83D\uDD12'}
</button>
<button <button
onClick={() => _removeDatasource(ds.id)} onClick={() => _removeDatasource(ds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
@ -448,7 +703,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
</div> </div>
)} )}
{/* Tree header */} {/* ── Browse Sources header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}> <span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Browse Sources Browse Sources
@ -462,7 +717,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
</button> </button>
</div> </div>
{/* Tree */} {/* ── Browse Sources tree ── */}
{loadingRoot && tree.length === 0 && ( {loadingRoot && tree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}> <div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading connections... Loading connections...
@ -487,10 +742,10 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
/> />
))} ))}
{/* ── Feature Data Section ── */} {/* ── Divider ── */}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} /> <div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
{/* Active Feature Data Sources */} {/* ── Active Feature Sources ── */}
{featureDataSources.length > 0 && ( {featureDataSources.length > 0 && (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}> <div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
@ -500,33 +755,55 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
const fdsConnLabel = meta?.instanceLabel || fds.tableName; const fdsConnLabel = meta?.instanceLabel || fds.tableName;
return ( return (
<div key={fds.id} style={{ <div key={fds.id} style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
padding: '4px 6px', borderRadius: 4, marginBottom: 2, padding: '4px 6px', borderRadius: 4, marginBottom: 2,
background: '#7b1fa218', background: '#7b1fa218',
borderLeft: '3px solid #7b1fa2', borderLeft: '3px solid #7b1fa2',
fontSize: 12, fontSize: 12,
}} title={_featureDataSourceHoverTitle(meta, fds)}> }} title={_featureDataSourceHoverTitle(meta, fds)}>
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}> <span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span> </span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fdsConnLabel} {fds.tableName} {fdsConnLabel} {fds.tableName}
</span> </span>
<button <button
onClick={() => _removeFeatureDataSource(fds.id)} onClick={() => _cycleFeatureScope(fds)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }} style={{
title="Entfernen" background: 'none', border: 'none', cursor: 'pointer',
> fontSize: 13, padding: '0 2px', lineHeight: 1,
{'\u2715'} }}
</button> title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope}${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
</div> >
); })} {_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
</button>
<button
onClick={() => _toggleFeatureNeutralize(fds)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 13, padding: '0 2px', lineHeight: 1,
opacity: fds.neutralize ? 1 : 0.35,
}}
title={fds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
>
{'\uD83D\uDD12'}
</button>
<button
onClick={() => _removeFeatureDataSource(fds.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
title="Entfernen"
>
{'\u2715'}
</button>
</div>
);
})}
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} /> <div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
</div> </div>
)} )}
{/* Feature Connections Tree */} {/* ── Feature Data header ── */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}> <span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
Feature Data Feature Data
@ -540,6 +817,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
</button> </button>
</div> </div>
{/* ── Feature Data tree ── */}
{loadingFeatures && featureTree.length === 0 && ( {loadingFeatures && featureTree.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}> <div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
Loading feature instances... Loading feature instances...
@ -567,9 +845,9 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
); );
}; };
/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */ /* ─── TreeNodeView (recursive) ──────────────────────────────────────── */
interface TreeNodeViewProps { interface _TreeNodeViewProps {
node: TreeNode; node: TreeNode;
depth: number; depth: number;
onToggle: (node: TreeNode) => void; onToggle: (node: TreeNode) => void;
@ -578,7 +856,7 @@ interface TreeNodeViewProps {
addingPath: string | null; addingPath: string | null;
} }
const _TreeNodeView: React.FC<TreeNodeViewProps> = ({ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
node, depth, onToggle, onAdd, isAdded, addingPath, node, depth, onToggle, onAdd, isAdded, addingPath,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@ -646,7 +924,6 @@ const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
)} )}
</div> </div>
{/* Children */}
{node.expanded && node.children && node.children.length > 0 && ( {node.expanded && node.children && node.children.length > 0 && (
<div> <div>
{node.children.map(child => ( {node.children.map(child => (
@ -672,9 +949,9 @@ const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
); );
}; };
/* ─── MandateGroupView (mandate + feature instances) ───────────────── */ /* ─── MandateGroupView (mandate + feature instances) ─────────────────── */
interface MandateGroupViewProps { interface _MandateGroupViewProps {
group: MandateGroupNode; group: MandateGroupNode;
onToggleGroup: (mandateId: string) => void; onToggleGroup: (mandateId: string) => void;
onToggleFeature: (node: FeatureConnectionNode) => void; onToggleFeature: (node: FeatureConnectionNode) => void;
@ -683,7 +960,7 @@ interface MandateGroupViewProps {
addingKey: string | null; addingKey: string | null;
} }
const _MandateGroupView: React.FC<MandateGroupViewProps> = ({ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@ -729,9 +1006,9 @@ const _MandateGroupView: React.FC<MandateGroupViewProps> = ({
); );
}; };
/* ─── FeatureNodeView (feature instance + tables) ─────────────────── */ /* ─── FeatureNodeView (feature instance + tables) ────────────────────── */
interface FeatureNodeViewProps { interface _FeatureNodeViewProps {
node: FeatureConnectionNode; node: FeatureConnectionNode;
onToggle: (node: FeatureConnectionNode) => void; onToggle: (node: FeatureConnectionNode) => void;
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
@ -739,7 +1016,7 @@ interface FeatureNodeViewProps {
addingKey: string | null; addingKey: string | null;
} }
const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
node, onToggle, onAddTable, isTableAdded, addingKey, node, onToggle, onAddTable, isTableAdded, addingKey,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@ -797,7 +1074,9 @@ const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({
); );
}; };
interface FeatureTableRowProps { /* ─── FeatureTableRow ────────────────────────────────────────────────── */
interface _FeatureTableRowProps {
featureNode: FeatureConnectionNode; featureNode: FeatureConnectionNode;
table: FeatureTableNode; table: FeatureTableNode;
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
@ -805,7 +1084,7 @@ interface FeatureTableRowProps {
isAdding: boolean; isAdding: boolean;
} }
const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
featureNode, table, onAdd, isAdded, isAdding, featureNode, table, onAdd, isAdded, isAdding,
}) => { }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@ -852,92 +1131,4 @@ const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({
); );
}; };
/* ─── Spinner (inline) ──────────────────────────────────────────────── */ export default SourcesTab;
function _Spinner(): React.ReactElement {
return (
<span style={{
display: 'inline-block', width: 10, height: 10,
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 0.6s linear infinite',
}} />
);
}
/* ─── Data fetching ─────────────────────────────────────────────────── */
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
const services = res.data.services || [];
return services.map((s: any) => ({
key: `svc-${connectionId}-${s.service}`,
label: s.label || s.service,
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
type: 'service' as const,
expanded: false,
loading: false,
children: null,
connectionId,
service: s.service,
path: '/',
displayPath: s.label || s.service,
}));
}
async function _browseService(
instanceId: string,
connectionId: string,
service: string,
path: string,
parentDisplayPath: string | undefined,
): Promise<TreeNode[]> {
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
params: { service, path },
});
const items = res.data.items || [];
return items.map((entry: any, idx: number) => {
const seg = entry.name || '';
const displayPath = parentDisplayPath
? `${parentDisplayPath} / ${seg}`
: seg;
return {
key: `item-${connectionId}-${service}-${entry.path || idx}`,
label: entry.name,
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
type: entry.isFolder ? 'folder' as const : 'file' as const,
expanded: false,
loading: false,
children: entry.isFolder ? null : [],
connectionId,
service,
path: entry.path,
displayPath,
};
});
}
function _fileIcon(name: string): string {
const ext = name.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
};
return map[ext] || '\uD83D\uDCC4';
}
/* ─── Tree map utility ──────────────────────────────────────────────── */
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
return nodes.map(n => {
if (n.key === key) return updater(n);
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
return n;
});
}

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

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

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

@ -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, useEffect } 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(--color-primary, #3b82f6)',
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

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

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

@ -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,6 +22,7 @@ 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);
@ -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

@ -14,6 +14,7 @@ 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 } from 'react-icons/fa';
@ -23,6 +24,7 @@ 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,
@ -111,11 +113,18 @@ 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}" unwiderruflich zu löschen, geben Sie den Namen ein:`,
{ title: 'Mandant löschen', confirmLabel: 'Löschen', variant: 'danger', placeholder: mandate.name },
);
if (entered === null) return;
if (entered !== mandate.name) {
showWarning('Löschung abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
return;
} }
await handleDelete(mandate.id); await handleDelete(mandate.id);
}; };
@ -267,6 +276,8 @@ export const AdminMandatesPage: React.FC = () => {
</div> </div>
)} )}
<PromptDialog />
{/* Edit Modal */} {/* Edit Modal */}
{editingFormData && ( {editingFormData && (
<div <div

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

@ -220,7 +220,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 +244,9 @@ 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, ClickUp)
</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button <button

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]);
@ -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>
@ -782,9 +756,6 @@ export const BillingAdmin: React.FC = () => {
<> <>
{isSysAdmin && ( {isSysAdmin && (
<CreditAdder <CreditAdder
settings={settings}
accounts={accounts}
users={users}
onAddCredit={_handleAddCredit} onAddCredit={_handleAddCredit}
/> />
)} )}

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>
); );
}; };
@ -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, #3b82f6)';
<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, #3b82f6)';
return (
<div key={sv.mandateId} style={{
background: 'var(--bg-secondary, #2a2a2a)',
borderRadius: '8px',
padding: '14px 16px',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
}}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
{sv.mandateName}
</span>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', fontFamily: 'monospace' }}>
{usedLabel} / {maxLabel}
{sv.percentUsed != null && (
<span style={{ marginLeft: '8px', color: barColor, fontWeight: 600 }}>
({pct.toFixed(1)}%)
</span>
)}
</span>
</div>
{sv.maxDataVolumeMB != null && (
<div style={{
height: '10px',
background: 'var(--surface-color, #1e1e1e)',
borderRadius: '5px',
overflow: 'hidden',
marginBottom: '8px',
}}>
<div style={{
height: '100%',
width: `${Math.min(pct, 100)}%`,
background: barColor,
borderRadius: '5px',
transition: 'width 0.4s ease',
minWidth: pct > 0 ? '4px' : '0',
}} />
</div>
)}
<div style={{
display: 'flex',
gap: '16px',
fontSize: '0.8rem',
color: 'var(--text-secondary, #888)',
}}>
<span>Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)}</span>
<span>RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}</span>
</div>
</div>
);
})}
</div>
)}
</div>
</section>
{/* AI usage statistics */}
<section className={styles.section}>
<FormGeneratorReport
title="Nutzungsstatistik"
subtitle="Detaillierte Analyse der AI-Nutzung"
periodSelector={periodSelectorConfig}
onFilterChange={_handleStatsFilterChange}
loading={statsLoading}
sections={statisticsSections}
noDataMessage="Keine Statistiken verfügbar"
currencyCode="CHF"
/>
</section>
</>
)} )}
{/* ================================================================ */} {/* ================================================================ */}

View file

@ -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';
// ============================================================================ // ============================================================================
@ -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
// ============================================================================ // ============================================================================
@ -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, #888)' }}>
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, #888)', 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>
)} )}
@ -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>
)} )}
@ -358,7 +397,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',
}, },
); );

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

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

View file

@ -1,48 +1,54 @@
/** /**
* 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,
} from '../../../api/commcoachApi'; } from '../../../api/commcoachApi';
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 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'; type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
export const CommcoachDossierView: React.FC = () => { export const CommcoachDossierView: React.FC = () => {
const coach = useCommcoach(); const coach = useCommcoach();
const { request } = useApiRequest(); const { request } = useApiRequest();
const instanceId = useInstanceId(); const instanceId = useInstanceId();
const mandateId = useMandateId();
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 _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;
@ -82,27 +88,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)
@ -118,6 +111,15 @@ export const CommcoachDossierView: React.FC = () => {
} }
}, [activeTab, coach.session?.id, voice]); }, [activeTab, coach.session?.id, voice]);
useEffect(() => {
coach.onDocumentCreatedRef.current = () => {
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
};
return () => {
coach.onDocumentCreatedRef.current = null;
};
}, [coach]);
const handleStopTts = useCallback(() => coach.stopTts(), [coach]); const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]); const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]); const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
@ -144,46 +146,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 +157,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 +271,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>
@ -546,41 +531,9 @@ 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 */} {/* #region agent log */}
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}> <div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
<button <button
@ -595,6 +548,7 @@ export const CommcoachDossierView: React.FC = () => {
</div> </div>
{/* #endregion */} {/* #endregion */}
</div> </div>
</div>
); );
}; };
@ -607,13 +561,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 +587,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',

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';
@ -30,6 +32,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 +42,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 +84,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 +114,15 @@ 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 toggleMute = useCallback(() => { const toggleMute = useCallback(() => {
const cur = stateRef.current; const cur = stateRef.current;
@ -110,13 +130,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,

View file

@ -110,7 +110,7 @@ export const RealEstateParcelsView: React.FC = () => {
}; };
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
return (attributes || []).filter(attr => !excluded.includes(attr.name)); return (attributes || []).filter(attr => !excluded.includes(attr.name));
}, [attributes]); }, [attributes]);

View file

@ -106,7 +106,7 @@ export const RealEstateProjectsView: React.FC = () => {
}; };
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
return (attributes || []).filter(attr => !excluded.includes(attr.name)); return (attributes || []).filter(attr => !excluded.includes(attr.name));
}, [attributes]); }, [attributes]);

View file

@ -150,7 +150,7 @@ export const TrusteeDocumentsView: React.FC = () => {
// Form attributes (exclude system fields) // Form attributes (exclude system fields)
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]); }, [attributes]);

View file

@ -52,7 +52,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
if (!attributes || attributes.length === 0) return []; if (!attributes || attributes.length === 0) return [];
// Exclude system fields from table columns // Exclude system fields from table columns
const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
return attributes return attributes
.filter((attr: any) => !excludedFields.includes(attr.name)) .filter((attr: any) => !excludedFields.includes(attr.name))
@ -127,7 +127,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
// Form attributes (exclude system fields) // Form attributes (exclude system fields)
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
return (attributes || []).filter((attr: any) => !excludedFields.includes(attr.name)); return (attributes || []).filter((attr: any) => !excludedFields.includes(attr.name));
}, [attributes]); }, [attributes]);

View file

@ -257,7 +257,7 @@ export const TrusteePositionsView: React.FC = () => {
const positionColumnOrder = [ const positionColumnOrder = [
'_documentRefs', // Belege (download icons) '_documentRefs', // Belege (download icons)
'_syncStatus', // Sync-Status '_syncStatus', // Sync-Status
'_createdAt', // Erstellt am 'sysCreatedAt', // Erstellt am
'valuta', // Valuta date 'valuta', // Valuta date
'tags', 'tags',
'company', 'company',
@ -372,7 +372,7 @@ export const TrusteePositionsView: React.FC = () => {
// Form attributes (exclude system fields) // Form attributes (exclude system fields)
const formAttributes = useMemo(() => { const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]); }, [attributes]);

View file

@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import api from '../../../api'; import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { AgentProgress, FileEditProposal } from './useWorkspace'; import type { AgentProgress, FileEditProposal } from './useWorkspace';
@ -147,6 +148,44 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
charCount={(msg as any)._audioCharCount} charCount={(msg as any)._audioCharCount}
/> />
)} )}
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
<details className="sentDataDetails" style={{ marginTop: 8, fontSize: '0.8rem', borderTop: '1px solid var(--border-color, #e5e7eb)', paddingTop: 6 }}>
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #6b7280)', fontWeight: 500, userSelect: 'none' }}>
Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'})
</summary>
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
{msg.documents.map((doc, idx) => (
<div key={doc.id || idx} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-hover, rgba(0,0,0,0.02))', borderRadius: 4 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.documentName || doc.fileName || `Dokument ${idx + 1}`}
</span>
{doc.validationMetadata?.neutralized && (
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#dcfce7', color: '#166534' }}>
neutralisiert
</span>
)}
{doc.validationMetadata?.skipped && (
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#fef2f2', color: '#991b1b' }}>
übersprungen
</span>
)}
</div>
))}
</div>
{(msg as any).neutralizationExcluded?.length > 0 && (
<div style={{ marginTop: 6, padding: '6px 8px', background: '#fef2f2', borderRadius: 4, border: '1px solid #fecaca' }}>
<div style={{ fontWeight: 600, color: '#991b1b', marginBottom: 4 }}>
Nicht gesendet (Neutralisierung fehlgeschlagen):
</div>
{(msg as any).neutralizationExcluded.map((docName: string, i: number) => (
<div key={i} style={{ fontSize: '0.75rem', color: '#991b1b', paddingLeft: 4 }}>
{docName}
</div>
))}
</div>
)}
</details>
)}
</div> </div>
)} )}
</div> </div>
@ -301,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || ''; const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
const icon = _getFileIcon(ext); const icon = _getFileIcon(ext);
const sizeLabel = doc.fileSize const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
? doc.fileSize > 1024 * 1024
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
: `${(doc.fileSize / 1024).toFixed(1)} KB`
: '';
return ( return (
<div <div

View file

@ -1,438 +0,0 @@
/**
* ConversationList -- Shows all workspace workflows/conversations.
*
* Features: filter, rename (double-click), delete, archive, create new,
* pagination (20 per page), last-activity display.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import api from '../../../api';
const _PAGE_SIZE = 20;
interface Conversation {
id: string;
name: string;
status: string;
startedAt?: number;
lastActivity?: number;
}
interface ConversationListProps {
instanceId: string;
activeWorkflowId: string | null;
onSelect: (workflowId: string) => void;
onCreateNew?: () => void;
refreshTrigger?: number;
}
export const ConversationList: React.FC<ConversationListProps> = ({
instanceId,
activeWorkflowId,
onSelect,
onCreateNew,
refreshTrigger,
}) => {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [filterQuery, setFilterQuery] = useState('');
const [page, setPage] = useState(0);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'active' | 'archived'>('active');
const inputRef = useRef<HTMLInputElement>(null);
const _loadConversations = useCallback(() => {
if (!instanceId) return;
setLoading(true);
api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } })
.then(res => {
const items = (res.data.workflows || res.data || [])
.map((w: any) => ({
id: w.id,
name: w.name || w.label || 'Untitled',
status: w.status || 'unknown',
startedAt: w.startedAt || w.createdAt,
lastActivity: w.lastActivity || w.updatedAt || w.startedAt,
}))
.sort((a: Conversation, b: Conversation) =>
(b.lastActivity || 0) - (a.lastActivity || 0),
);
setConversations(items);
})
.catch(() => setConversations([]))
.finally(() => setLoading(false));
}, [instanceId]);
useEffect(() => {
_loadConversations();
}, [_loadConversations]);
useEffect(() => {
if (refreshTrigger) _loadConversations();
}, [refreshTrigger, _loadConversations]);
useEffect(() => {
if (activeWorkflowId && !conversations.find(c => c.id === activeWorkflowId)) {
_loadConversations();
}
}, [activeWorkflowId, conversations, _loadConversations]);
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingId]);
const _formatTime = (ts?: number): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
if (diffDays === 1) return 'Gestern';
if (diffDays < 7) return `vor ${diffDays}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const _formatDate = (ts?: number): string => {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' })
+ ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const _startEditing = (conv: Conversation) => {
setEditingId(conv.id);
setEditName(conv.name);
};
const _commitRename = (convId: string) => {
const trimmed = editName.trim();
if (!trimmed) {
setEditingId(null);
return;
}
setConversations(prev =>
prev.map(c => c.id === convId ? { ...c, name: trimmed } : c),
);
setEditingId(null);
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed })
.catch(() => _loadConversations());
};
const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitRename(convId);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const _handleDelete = (convId: string) => {
setConversations(prev => prev.filter(c => c.id !== convId));
if (activeWorkflowId === convId) onSelect('');
api.delete(`/api/workspace/${instanceId}/workflows/${convId}`)
.catch(() => _loadConversations());
};
const _handleArchive = (convId: string) => {
setConversations(prev => prev.map(c =>
c.id === convId ? { ...c, status: 'archived' } : c,
));
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' })
.catch(() => _loadConversations());
};
const _handleReactivate = (convId: string) => {
setConversations(prev => prev.map(c =>
c.id === convId ? { ...c, status: 'active' } : c,
));
api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' })
.catch(() => _loadConversations());
};
const _handleCreateNew = () => {
if (onCreateNew) onCreateNew();
};
const _filtered = (items: Conversation[], query: string): Conversation[] => {
if (!query.trim()) return items;
const q = query.toLowerCase();
return items.filter(c =>
c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q),
);
};
const _byStatus = viewMode === 'archived'
? conversations.filter(c => c.status === 'archived')
: conversations.filter(c => c.status !== 'archived');
const filtered = _filtered(_byStatus, filterQuery);
const totalPages = Math.ceil(filtered.length / _PAGE_SIZE);
const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE);
const _archivedCount = conversations.filter(c => c.status === 'archived').length;
const _activeCount = conversations.filter(c => c.status !== 'archived').length;
useEffect(() => { setPage(0); }, [filterQuery, viewMode]);
return (
<div style={{ padding: 8 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Conversations</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={_handleCreateNew}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#1976d2' }}
title="Neuer Chat"
>
+
</button>
<button
onClick={_loadConversations}
disabled={loading}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}
>
{loading ? '...' : '\u21BB'}
</button>
</div>
</div>
{/* View mode toggle */}
<div style={{ display: 'flex', marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #ddd' }}>
<button
onClick={() => setViewMode('active')}
style={{
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
background: viewMode === 'active' ? 'var(--primary-color, #1976d2)' : 'transparent',
color: viewMode === 'active' ? '#fff' : '#888',
transition: 'background 0.15s, color 0.15s',
}}
>
Aktiv {_activeCount > 0 && <span style={{ fontWeight: 400 }}>({_activeCount})</span>}
</button>
<button
onClick={() => setViewMode('archived')}
style={{
flex: 1, padding: '5px 0', fontSize: 11, fontWeight: 600, border: 'none', cursor: 'pointer',
borderLeft: '1px solid #ddd',
background: viewMode === 'archived' ? '#ff9800' : 'transparent',
color: viewMode === 'archived' ? '#fff' : '#888',
transition: 'background 0.15s, color 0.15s',
}}
>
Archiv {_archivedCount > 0 && <span style={{ fontWeight: 400 }}>({_archivedCount})</span>}
</button>
</div>
{/* Filter */}
{filtered.length > 3 && (
<input
type="text"
placeholder="Filter chats..."
value={filterQuery}
onChange={e => setFilterQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box',
}}
/>
)}
{/* Empty state */}
{filtered.length === 0 && !loading && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{viewMode === 'archived'
? 'Keine archivierten Chats.'
: 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'}
</div>
)}
{/* List */}
{paginated.map(conv => {
const isActive = conv.id === activeWorkflowId;
const isEditing = editingId === conv.id;
return (
<div
key={conv.id}
onClick={() => { if (!isEditing) onSelect(conv.id); }}
style={{
padding: '8px 10px',
marginBottom: 4,
borderRadius: 6,
cursor: isEditing ? 'default' : 'pointer',
background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent',
border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent',
transition: 'background 0.15s',
position: 'relative',
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = '#f5f5f5';
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
if (actions) actions.style.opacity = '1';
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent';
const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement;
if (actions) actions.style.opacity = '0';
if (confirmDeleteId === conv.id) setConfirmDeleteId(null);
}}
>
{/* Name row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 4 }}>
{isEditing ? (
<input
ref={inputRef}
value={editName}
onChange={e => setEditName(e.target.value)}
onBlur={() => _commitRename(conv.id)}
onKeyDown={e => _handleKeyDown(e, conv.id)}
onClick={e => e.stopPropagation()}
style={{
flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600,
padding: '1px 4px', borderRadius: 3,
border: '1px solid var(--primary-color, #1976d2)',
outline: 'none', background: '#fff',
}}
/>
) : (
<>
<span
style={{ fontSize: 10, color: '#aaa', flexShrink: 0, marginRight: 6 }}
title={_formatDate(conv.lastActivity)}
>
{_formatTime(conv.lastActivity)}
</span>
<span
style={{
fontSize: 13,
fontWeight: isActive ? 600 : 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0,
}}
onDoubleClick={(e) => { e.stopPropagation(); _startEditing(conv); }}
title={conv.name}
>
{conv.name}
</span>
</>
)}
{/* Action buttons (visible on hover) */}
{!isEditing && (
<span
data-actions=""
style={{ display: 'flex', gap: 2, opacity: 0, transition: 'opacity 0.15s', flexShrink: 0 }}
>
<button
onClick={e => { e.stopPropagation(); _startEditing(conv); }}
style={_actionBtnStyle}
title="Umbenennen"
>
&#x270E;
</button>
{conv.status === 'archived' ? (
<button
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
style={{ ..._actionBtnStyle, color: '#4caf50' }}
title="Reaktivieren"
>
&#x21A9;
</button>
) : (
<button
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
style={_actionBtnStyle}
title="Archivieren"
>
&#x1F4E6;
</button>
)}
{confirmDeleteId === conv.id ? (
<span style={{
display: 'inline-flex', gap: 1, background: 'var(--color-secondary, #555)',
borderRadius: 12, padding: '1px 2px', alignItems: 'center',
}}>
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); _handleDelete(conv.id); }}
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
title="Ja, loeschen"
>
&#x2713;
</button>
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
title="Abbrechen"
>
&#x2717;
</button>
</span>
) : (
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
title="Loeschen"
>
&#x1F5D1;
</button>
)}
</span>
)}
</div>
</div>
);
})}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, marginTop: 8, fontSize: 12 }}>
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
style={{ ..._pageBtnStyle, opacity: page === 0 ? 0.3 : 1 }}
>
&lt;
</button>
<span style={{ color: '#888' }}>{page + 1} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
style={{ ..._pageBtnStyle, opacity: page >= totalPages - 1 ? 0.3 : 1 }}
>
&gt;
</button>
</div>
)}
</div>
);
};
const _actionBtnStyle: React.CSSProperties = {
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 11,
color: '#999',
padding: '0 2px',
};
const _pageBtnStyle: React.CSSProperties = {
background: 'none',
border: '1px solid #ddd',
borderRadius: 4,
cursor: 'pointer',
padding: '2px 8px',
color: '#666',
};

View file

@ -1,252 +0,0 @@
/**
* FileBrowser -- Folder-tree file browser for workspace.
*
* Uses useFileContext() for folders (shared state with Dateien page).
* Uses FolderTree with showFiles=true so folders and files render inline.
*/
import React, { useState, useCallback, useRef, useMemo } from 'react';
import api from '../../../api';
import FolderTree from '../../../components/FolderTree/FolderTree';
import type { FileNode } from '../../../components/FolderTree/FolderTree';
import { useFileContext } from '../../../contexts/FileContext';
import type { WorkspaceFile } from './useWorkspace';
interface FileBrowserProps {
instanceId: string;
files: WorkspaceFile[];
onRefresh: () => void;
onFileSelect?: (fileId: string) => void;
}
export const FileBrowser: React.FC<FileBrowserProps> = ({
instanceId,
files,
onRefresh,
onFileSelect,
}) => {
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 _folderNodes = useMemo(() =>
folders.map(f => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
})),
[folders],
);
const _fileNodes: FileNode[] = useMemo(() => {
let result: WorkspaceFile[] = 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,
}));
}, [files, searchQuery]);
const _refreshAll = useCallback(() => {
onRefresh();
refreshFolders();
}, [onRefresh, refreshFolders]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!instanceId || uploading) return;
setUploading(true);
try {
for (const file of Array.from(fileList)) {
const formData = new FormData();
formData.append('file', file);
formData.append('featureInstanceId', 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);
}
}, [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);
onRefresh();
}, [handleMoveFile, onRefresh]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
onRefresh();
}, [contextMoveFiles, onRefresh]);
const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
onRefresh();
}, [handleDeleteFolder, selectedFolderId, onRefresh]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName });
onRefresh();
}, [onRefresh]);
const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
onRefresh();
}, [handleFileDelete, onRefresh]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds });
onRefresh();
}, [onRefresh]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
refreshFolders();
onRefresh();
}, [refreshFolders, onRefresh]);
return (
<div
style={{ padding: 8, position: 'relative', display: 'flex', flexDirection: 'column', gap: 4 }}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #1976d2', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#1976d2',
}}>
Dateien hier ablegen
</div>
)}
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<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: '#1976d2' }}
title="Upload files"
>
{uploading ? '...' : '+'}
</button>
<button onClick={_refreshAll} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#1976d2' }}>{'\u21BB'}</button>
</div>
</div>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
{/* Search */}
<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',
}}
/>
{/* Folder tree with inline files */}
<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}
/>
{_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>
);
};

View file

@ -10,6 +10,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import api from '../../../api'; import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { WorkspaceFile } from './useWorkspace'; import type { WorkspaceFile } from './useWorkspace';
interface FilePreviewProps { interface FilePreviewProps {
@ -76,7 +77,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
</div> </div>
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}> <div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
<span>{file.mimeType}</span> <span>{file.mimeType}</span>
<span>{_formatFileSize(file.fileSize)}</span> <span>{formatBinaryDataSizeBytes(file.fileSize)}</span>
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>} {file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
</div> </div>
{file.description && ( {file.description && (
@ -156,8 +157,3 @@ function _isTextMime(mime: string): boolean {
return textTypes.includes(mime); return textTypes.includes(mime);
} }
function _formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View file

@ -0,0 +1,464 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import api from '../../../api';
const _chatPromptSourceId = '__chat_prompt__';
const _placeholderRx = /\[([a-z]+)\.([a-f0-9-]{36})\]/g;
interface NeutralizationMapping {
id: string;
originalText: string;
placeholder: string;
patternType: string;
fileId?: string;
fileName?: string;
createdAt?: string;
}
interface NeutralizationSnapshot {
id: string;
sourceLabel: string;
neutralizedText: string;
placeholderCount: number;
}
interface NeutralizationSource {
fileId: string;
fileName: string;
neutralizationStatus: string;
mappingCount: number;
isVirtual?: boolean;
}
interface NeutralizationPanelProps {
instanceId: string;
}
function _normalizeApiRow(raw: Record<string, unknown>): NeutralizationMapping {
const id = String(raw.id ?? '');
const patternType = String(raw.patternType ?? 'unknown');
const existingPh = raw.placeholder;
const placeholder =
typeof existingPh === 'string' && existingPh
? existingPh
: id
? `[${patternType}.${id}]`
: '';
return {
id,
originalText: String(raw.originalText ?? ''),
placeholder,
patternType,
fileId: raw.fileId != null && raw.fileId !== '' ? String(raw.fileId) : undefined,
fileName: raw.fileName != null ? String(raw.fileName) : undefined,
createdAt:
raw.createdAt != null
? String(raw.createdAt)
: raw.sysCreatedAt != null
? String(raw.sysCreatedAt)
: undefined,
};
}
function _partitionAttributes(rows: unknown[]): {
byFile: Record<string, NeutralizationMapping[]>;
unscoped: NeutralizationMapping[];
} {
const byFile: Record<string, NeutralizationMapping[]> = {};
const unscoped: NeutralizationMapping[] = [];
for (const item of rows) {
if (!item || typeof item !== 'object') continue;
const raw = item as Record<string, unknown>;
const m = _normalizeApiRow(raw);
const fid = raw.fileId;
if (fid == null || fid === '') {
unscoped.push(m);
} else {
const key = String(fid);
if (!byFile[key]) byFile[key] = [];
byFile[key].push(m);
}
}
return { byFile, unscoped };
}
const _phTypeColors: Record<string, string> = {
name: '#7c3aed',
email: '#2563eb',
phone: '#0891b2',
address: '#059669',
financial: '#d97706',
id: '#dc2626',
logic: '#be185d',
company: '#4f46e5',
product: '#7c3aed',
location: '#059669',
other: '#6b7280',
};
function _renderHighlightedText(
text: string,
mappingLookup: Map<string, NeutralizationMapping>,
): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIdx = 0;
const rx = new RegExp(_placeholderRx.source, 'g');
let match: RegExpExecArray | null;
while ((match = rx.exec(text)) !== null) {
if (match.index > lastIdx) {
parts.push(<span key={`t-${lastIdx}`}>{text.slice(lastIdx, match.index)}</span>);
}
const phType = match[1];
const phId = match[2];
const fullPh = match[0];
const mapping = mappingLookup.get(phId);
const color = _phTypeColors[phType] || _phTypeColors.other;
parts.push(
<span
key={`ph-${match.index}`}
title={mapping ? `${mapping.originalText} (${phType})` : phType}
style={{
background: color + '18',
color,
border: `1px solid ${color}40`,
borderRadius: 4,
padding: '1px 4px',
fontFamily: 'monospace',
fontSize: '0.78rem',
cursor: 'help',
whiteSpace: 'nowrap',
}}
>
{fullPh}
</span>,
);
lastIdx = match.index + match[0].length;
}
if (lastIdx < text.length) {
parts.push(<span key={`t-${lastIdx}`}>{text.slice(lastIdx)}</span>);
}
return parts;
}
const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId }) => {
const [sources, setSources] = useState<NeutralizationSource[]>([]);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
const [loading, setLoading] = useState(true);
const [attributeByFile, setAttributeByFile] = useState<Record<string, NeutralizationMapping[]>>({});
const [attributeUnscoped, setAttributeUnscoped] = useState<NeutralizationMapping[]>([]);
const [snapshots, setSnapshots] = useState<NeutralizationSnapshot[]>([]);
const [expandedSnapshot, setExpandedSnapshot] = useState<string | null>(null);
const _mappingLookup = useMemo(() => {
const map = new Map<string, NeutralizationMapping>();
for (const m of attributeUnscoped) map.set(m.id, m);
for (const arr of Object.values(attributeByFile)) {
for (const m of arr) map.set(m.id, m);
}
return map;
}, [attributeUnscoped, attributeByFile]);
const _loadSources = useCallback(async () => {
setLoading(true);
try {
const headers = instanceId ? { 'X-Instance-Id': instanceId } : undefined;
const [filesResponse, attrResponse] = await Promise.all([
api.get(`/api/workspace/${instanceId}/files`, { headers }),
api.get('/api/neutralization/attributes', { headers }),
]);
let snapAxios: { data: unknown } = { data: [] };
try {
const _enc = encodeURIComponent(instanceId);
snapAxios = await api.get(`/api/neutralization/${_enc}/snapshots`, { headers });
} catch (_snapErr) {
console.warn('NeutralizationPanel: scoped snapshots failed, trying unscoped:', _snapErr);
try {
snapAxios = await api.get('/api/neutralization/snapshots', { headers });
} catch (_snapErr2) {
console.error('NeutralizationPanel: snapshots could not be loaded:', _snapErr2);
snapAxios = { data: [] };
}
}
const rawFiles = filesResponse.data;
const files = Array.isArray(rawFiles) ? rawFiles : (rawFiles?.files || rawFiles?.data || []);
const fileList = Array.isArray(files) ? files : [];
const attrPayload = attrResponse.data?.data ?? attrResponse.data ?? [];
const attrRows = Array.isArray(attrPayload) ? attrPayload : [];
const { byFile, unscoped } = _partitionAttributes(attrRows);
setAttributeByFile(byFile);
setAttributeUnscoped(unscoped);
const _snapBody = snapAxios.data as { data?: unknown } | unknown[] | null | undefined;
const snapPayload =
Array.isArray(_snapBody) ? _snapBody : (_snapBody && typeof _snapBody === 'object' && 'data' in _snapBody
? ( _snapBody as { data: unknown }).data
: _snapBody) ?? [];
const snapList: NeutralizationSnapshot[] = Array.isArray(snapPayload) ? (snapPayload as NeutralizationSnapshot[]) : [];
setSnapshots(snapList);
if (snapList.length > 0 && snapList[0].id) {
setExpandedSnapshot(snapList[0].id);
} else {
setExpandedSnapshot(null);
}
const neutralizedFiles = fileList.filter((f: Record<string, unknown>) => f.neutralize);
const nextSources: NeutralizationSource[] = [];
if (unscoped.length > 0) {
nextSources.push({
fileId: _chatPromptSourceId,
fileName: 'Chat, Prompt & Kontext',
neutralizationStatus: 'completed',
mappingCount: unscoped.length,
isVirtual: true,
});
}
for (const f of neutralizedFiles) {
const fid = String(f.id ?? '');
if (!fid) continue;
nextSources.push({
fileId: fid,
fileName: String(f.fileName ?? f.name ?? 'unknown'),
neutralizationStatus: String(f.neutralizationStatus ?? f.status ?? 'unknown'),
mappingCount: byFile[fid]?.length ?? 0,
});
}
setSources(nextSources);
} catch (err) {
console.error('Failed to load neutralization sources:', err);
} finally {
setLoading(false);
}
}, [instanceId]);
useEffect(() => {
_loadSources();
}, [_loadSources]);
useEffect(() => {
if (!selectedSource) {
setMappings([]);
return;
}
if (selectedSource === _chatPromptSourceId) {
setMappings(attributeUnscoped);
return;
}
setMappings(attributeByFile[selectedSource] ?? []);
}, [selectedSource, attributeByFile, attributeUnscoped]);
const _handleDeleteMapping = async (mappingId: string) => {
try {
await api.delete(`/api/neutralization/attributes/single/${mappingId}`, {
headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined,
});
await _loadSources();
} catch (err) {
console.error('Failed to delete mapping:', err);
}
};
const _handleRetrigger = async (fileId: string) => {
try {
await api.post(
'/api/neutralization/retrigger',
{ fileId },
{ headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined },
);
await _loadSources();
} catch (err) {
console.error('Failed to retrigger neutralization:', err);
}
};
const _statusBadge = (status: string) => {
const colors: Record<string, { bg: string; text: string }> = {
completed: { bg: '#dcfce7', text: '#166534' },
pending: { bg: '#fef3c7', text: '#92400e' },
failed: { bg: '#fef2f2', text: '#991b1b' },
not_required: { bg: '#f3f4f6', text: '#6b7280' },
};
const c = colors[status] || colors.not_required;
return (
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: c.bg, color: c.text }}>
{status}
</span>
);
};
if (loading) return <div style={{ padding: 16, textAlign: 'center', color: '#6b7280' }}>Lade Neutralisierungsdaten...</div>;
const _hasAnyData = sources.length > 0 || snapshots.length > 0;
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Neutralisierung</h3>
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original &harr; Platzhalter).
</p>
{/* ── Snapshots: neutralisierter Text ──────────────────────── */}
{snapshots.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary, #111827)' }}>
Neutralisierter Text ({snapshots.length})
</div>
{snapshots.map((snap) => {
const _isExpanded = expandedSnapshot === snap.id;
return (
<div key={snap.id} style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
<div
onClick={() => setExpandedSnapshot(_isExpanded ? null : snap.id)}
style={{
padding: '8px 12px',
background: 'var(--bg-hover, #f9fafb)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '0.85rem',
}}
>
<span style={{ fontWeight: 500 }}>{snap.sourceLabel}</span>
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
{snap.placeholderCount} Platzhalter {_isExpanded ? '\u25BC' : '\u25B6'}
</span>
</div>
{_isExpanded && (
<div
style={{
padding: '10px 12px',
fontSize: '0.82rem',
lineHeight: 1.6,
maxHeight: 400,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'var(--bg-primary, #fff)',
}}
>
{_renderHighlightedText(snap.neutralizedText, _mappingLookup)}
</div>
)}
</div>
);
})}
</div>
)}
{/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */}
{sources.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary, #111827)' }}>
Datenquellen
</div>
{sources.map((src) => (
<div
key={src.fileId}
style={{
padding: '10px 14px',
borderRadius: 8,
border:
selectedSource === src.fileId ? '2px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
onClick={() => setSelectedSource(src.fileId === selectedSource ? null : src.fileId)}
>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>{src.fileName}</div>
<div style={{ fontSize: '0.8rem', color: '#6b7280', marginTop: 2 }}>
{_statusBadge(src.neutralizationStatus)}
{src.mappingCount > 0 && (
<span style={{ marginLeft: 8, color: '#9ca3af' }}>{src.mappingCount} Mapping(s)</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{!src.isVirtual && (
<button
onClick={(e) => {
e.stopPropagation();
_handleRetrigger(src.fileId);
}}
style={{
fontSize: '0.8rem',
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--border-color, #d1d5db)',
background: 'transparent',
cursor: 'pointer',
}}
>
Erneut neutralisieren
</button>
)}
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}</span>
</div>
</div>
))}
</div>
)}
{/* ── Mappings für ausgewählte Quelle ──────────────────────── */}
{selectedSource && mappings.length > 0 && (
<div style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '8px 16px', background: 'var(--bg-hover, #f9fafb)', fontSize: '0.85rem', fontWeight: 500 }}>
Platzhalter-Mappings ({mappings.length})
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{mappings.map((m) => (
<div
key={m.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
borderTop: '1px solid var(--border-color, #f3f4f6)',
fontSize: '0.8rem',
gap: 12,
}}
>
<span style={{ flex: 1, fontFamily: 'monospace', color: _phTypeColors[m.patternType] || '#4f46e5' }}>{m.placeholder}</span>
<span style={{ color: '#9ca3af' }}>{'\u2192'}</span>
<span style={{ flex: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={m.originalText}>
{m.originalText}
</span>
<span style={{ fontSize: '0.7rem', color: '#9ca3af', flexShrink: 0 }}>{m.patternType}</span>
<button
onClick={() => _handleDeleteMapping(m.id)}
style={{ color: '#ef4444', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.9rem', padding: '2px 6px' }}
title="Mapping löschen"
>
{'\u00D7'}
</button>
</div>
))}
</div>
</div>
)}
{selectedSource && mappings.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
{selectedSource === _chatPromptSourceId
? 'Keine Mappings ohne Dateizuordnung.'
: 'Keine gespeicherten Mappings für diese Datenquelle.'}
</div>
)}
{!_hasAnyData && (
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung.
</div>
)}
</div>
);
};
export default NeutralizationPanel;

View file

@ -15,6 +15,7 @@ import type { editor as monacoEditor } from 'monaco-editor';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor'; import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa'; import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
function _getMonacoLanguage(fileName: string): string { function _getMonacoLanguage(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || ''; const ext = fileName.split('.').pop()?.toLowerCase() || '';
@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string {
return langMap[ext] || 'plaintext'; return langMap[ext] || 'plaintext';
} }
function _formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export const WorkspaceEditorPage: React.FC = () => { export const WorkspaceEditorPage: React.FC = () => {
const instanceId = useInstanceId() || ''; const instanceId = useInstanceId() || '';
const navigate = useNavigate(); const navigate = useNavigate();
@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => {
}}> }}>
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}> <div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
<span>{activeEdit.fileName}</span> <span>{activeEdit.fileName}</span>
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span> <span>Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)}</span>
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span> <span>Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}</span>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<button <button

View file

@ -0,0 +1,16 @@
.settings { padding: 1rem; max-width: 640px; }
.heading { margin: 0 0 1.5rem; font-size: 1.25rem; font-weight: 600; color: var(--text-primary, #1a1a1a); }
.loading { padding: 2rem; text-align: center; color: #999; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; }
.success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #16a34a; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; }
.section { background: var(--surface-color, #fff); border: 1px solid var(--border-color, #e0e0e0); border-radius: 10px; padding: 1.25rem; margin-bottom: 1.5rem; }
.sectionTitle { margin: 0 0 1rem; font-size: 0.95rem; font-weight: 600; }
.field { margin-bottom: 1rem; }
.label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.35rem; }
.input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #d0d0d0); border-radius: 6px; font-size: 0.875rem; background: var(--bg-primary, #fff); color: var(--text-primary, #1a1a1a); }
.input:focus { outline: none; border-color: var(--primary-color, #2563eb); box-shadow: 0 0 0 2px rgba(37,99,235,0.1); }
.removeBtn { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0.5rem; }
.removeBtn:hover { text-decoration: underline; }
.saveBtn { padding: 0.625rem 1.5rem; background: var(--primary-color, #2563eb); color: #fff; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600; cursor: pointer; }
.saveBtn:hover { opacity: 0.9; }
.saveBtn:disabled { opacity: 0.5; cursor: not-allowed; }

View file

@ -6,7 +6,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import styles from './WorkspaceSettings.module.css'; import styles from './WorkspaceGeneralSettings.module.css';
interface GeneralSettingsProps { interface GeneralSettingsProps {
instanceId: string; instanceId: string;

View file

@ -5,6 +5,7 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ProviderMultiSelect } from '../../../components/ProviderSelector'; import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry'; import { getPageIcon } from '../../../config/pageRegistry';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
@ -38,7 +39,7 @@ interface TreeItemDrop {
interface WorkspaceInputProps { interface WorkspaceInputProps {
instanceId: string; instanceId: string;
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void; onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void;
isProcessing: boolean; isProcessing: boolean;
onStop: () => void; onStop: () => void;
files: WorkspaceFile[]; files: WorkspaceFile[];
@ -48,11 +49,13 @@ interface WorkspaceInputProps {
onRemovePendingFile?: (fileId: string) => void; onRemovePendingFile?: (fileId: string) => void;
onFileUploadClick?: () => void; onFileUploadClick?: () => void;
uploading?: boolean; uploading?: boolean;
selectedProviders?: string[]; providerSelection?: ProviderSelection;
onProvidersChange?: (providers: string[]) => void; onProviderSelectionChange?: (selection: ProviderSelection) => void;
isMobile?: boolean; isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void; onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onPasteAsFile?: (file: File) => void; onPasteAsFile?: (file: File) => void;
draftAppend?: string;
onDraftAppendConsumed?: () => void;
} }
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
@ -67,30 +70,47 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
onRemovePendingFile, onRemovePendingFile,
onFileUploadClick, onFileUploadClick,
uploading = false, uploading = false,
selectedProviders = [], providerSelection,
onProvidersChange, onProviderSelectionChange,
isMobile = false, isMobile = false,
onTreeItemsDrop, onTreeItemsDrop,
onPasteAsFile, onPasteAsFile,
draftAppend,
onDraftAppendConsumed,
}) => { }) => {
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [autocompleteFilter, setAutocompleteFilter] = useState('');
const [treeDropOver, setTreeDropOver] = useState(false); const [treeDropOver, setTreeDropOver] = useState(false);
const [voiceActive, setVoiceActive] = useState(false); const [voiceActive, setVoiceActive] = useState(false);
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE'); const [voiceLanguage, setVoiceLanguage] = useState('de-DE');
const [showLangPicker, setShowLangPicker] = useState(false); const [showLangPicker, setShowLangPicker] = useState(false);
const _sttPrefsLoaded = useRef(false);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]); const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [neutralizeActive, setNeutralizeActive] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (draftAppend) {
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
onDraftAppendConsumed?.();
}
}, [draftAppend, onDraftAppendConsumed]);
const promptBeforeVoiceRef = useRef(''); const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef(''); const finalizedTextRef = useRef('');
const currentInterimRef = useRef(''); const currentInterimRef = useRef('');
useEffect(() => { useEffect(() => {
localStorage.setItem('workspace_stt_lang', voiceLanguage); if (_sttPrefsLoaded.current) return;
}, [voiceLanguage]); _sttPrefsLoaded.current = true;
fetch('/api/voice/preferences', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
.catch(() => {});
}, []);
const _extractFileRefs = useCallback( const _extractFileRefs = useCallback(
(text: string): string[] => { (text: string): string[] => {
@ -116,12 +136,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
if (!trimmed || isProcessing) return; if (!trimmed || isProcessing) return;
const inlineFileIds = _extractFileRefs(trimmed); const inlineFileIds = _extractFileRefs(trimmed);
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds); const options = neutralizeActive ? { requireNeutralization: true } : undefined;
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
setPrompt(''); setPrompt('');
setShowAutocomplete(false); setShowAutocomplete(false);
setShowSourcePicker(false); setShowSourcePicker(false);
setAttachedFileIds([]); setAttachedFileIds([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]); }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
const _handleKeyDown = useCallback( const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -263,7 +284,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
}, [onPasteAsFile]); }, [onPasteAsFile]);
const _handlePromptDragOver = useCallback((e: React.DragEvent) => { const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) { if (
e.dataTransfer.types.includes('application/tree-items') ||
e.dataTransfer.types.includes('application/chat-id')
) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true); setTreeDropOver(true);
@ -273,11 +297,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []); const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
const _handlePromptDrop = useCallback((e: React.DragEvent) => { const _handlePromptDrop = useCallback((e: React.DragEvent) => {
setTreeDropOver(false);
const chatId = e.dataTransfer.getData('application/chat-id');
if (chatId) {
e.preventDefault();
e.stopPropagation();
const chatLabel = e.dataTransfer.getData('text/plain');
const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
setPrompt(prev => (prev ? `${prev} ${ref}` : ref));
return;
}
const treeItemsJson = e.dataTransfer.getData('application/tree-items'); const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson && onTreeItemsDrop) { if (treeItemsJson && onTreeItemsDrop) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setTreeDropOver(false);
const items: TreeItemDrop[] = JSON.parse(treeItemsJson); const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
onTreeItemsDrop(items); onTreeItemsDrop(items);
} }
@ -619,12 +654,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
</div> </div>
)} )}
{onProvidersChange && ( {onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect <ProviderMultiSelect
selectedProviders={selectedProviders} selection={providerSelection}
onChange={onProvidersChange} onChange={onProviderSelectionChange}
showLabel={false} showLabel={false}
excludeByDefault={['privatellm']}
disabled={isProcessing} disabled={isProcessing}
/> />
)} )}
@ -665,7 +699,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
{_STT_LANGUAGES.map(lang => ( {_STT_LANGUAGES.map(lang => (
<div <div
key={lang.code} key={lang.code}
onClick={() => { setVoiceLanguage(lang.code); setShowLangPicker(false); }} onClick={() => {
setVoiceLanguage(lang.code);
setShowLangPicker(false);
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
}}
style={{ style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 13, padding: '8px 12px', cursor: 'pointer', fontSize: 13,
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent', background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
@ -681,6 +719,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
)} )}
</div> </div>
<button
onClick={() => setNeutralizeActive(v => !v)}
title={neutralizeActive ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
style={{
padding: '8px 10px', borderRadius: 8, border: '1px solid',
borderColor: neutralizeActive ? '#166534' : 'var(--border-color, #d1d5db)',
background: neutralizeActive ? '#dcfce7' : 'transparent',
cursor: 'pointer', fontSize: '1rem', lineHeight: 1,
opacity: neutralizeActive ? 1 : 0.5,
transition: 'all 0.15s',
}}
>
🔒
</button>
{isProcessing ? ( {isProcessing ? (
<button <button
onClick={onStop} onClick={onStop}

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