Merge pull request #25 from valueonag/feat/unified-data-bar
Feat/unified data bar
This commit is contained in:
commit
6a66388def
109 changed files with 5050 additions and 2736 deletions
|
|
@ -27,6 +27,8 @@ export interface RegisterData {
|
|||
language?: string;
|
||||
enabled?: boolean;
|
||||
privilege?: string;
|
||||
registrationType?: 'personal' | 'company';
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
|
|
@ -40,6 +42,8 @@ export interface RegisterRequest {
|
|||
authenticationAuthority: string;
|
||||
};
|
||||
frontendUrl: string;
|
||||
registrationType?: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestResponse {
|
||||
|
|
@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise<RegisterR
|
|||
privilege: registerData.privilege || 'user',
|
||||
authenticationAuthority: 'local'
|
||||
},
|
||||
frontendUrl: window.location.origin
|
||||
frontendUrl: window.location.origin,
|
||||
registrationType: registerData.registrationType,
|
||||
companyName: registerData.companyName,
|
||||
};
|
||||
|
||||
// Prepare headers with CSRF token if available
|
||||
|
|
|
|||
|
|
@ -313,7 +313,7 @@ export interface Automation2Task {
|
|||
result?: Record<string, unknown>;
|
||||
/** Workflow label (enriched by API) */
|
||||
workflowLabel?: string;
|
||||
/** Unix timestamp ms (from _createdAt) */
|
||||
/** Unix timestamp ms (from sysCreatedAt) */
|
||||
createdAt?: number;
|
||||
/** Optional due date - configurable in future */
|
||||
dueAt?: number;
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ export interface Automation {
|
|||
nextExecution?: number;
|
||||
executionLogs?: AutomationLog[];
|
||||
allowedProviders?: string[];
|
||||
_createdAt?: number;
|
||||
sysCreatedAt?: number;
|
||||
_updatedAt?: number;
|
||||
_createdByUserName?: string;
|
||||
sysCreatedByUserName?: string;
|
||||
mandateName?: string;
|
||||
featureInstanceName?: string;
|
||||
[key: string]: any;
|
||||
|
|
@ -48,9 +48,9 @@ export interface AutomationTemplate {
|
|||
label: TextMultilingual;
|
||||
overview?: TextMultilingual;
|
||||
template: string; // JSON string with {{KEY:...}} placeholders
|
||||
_createdAt?: number;
|
||||
_createdBy?: string;
|
||||
_createdByUserName?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysCreatedByUserName?: string;
|
||||
}
|
||||
|
||||
// Workflow action definition from backend
|
||||
|
|
@ -301,7 +301,7 @@ export async function fetchAutomationTemplateById(
|
|||
*/
|
||||
export async function createAutomationTemplateApi(
|
||||
request: ApiRequestFunction,
|
||||
templateData: Omit<AutomationTemplate, 'id' | '_createdAt' | '_createdBy'>
|
||||
templateData: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>
|
||||
): Promise<AutomationTemplate> {
|
||||
return await request({
|
||||
url: '/api/automation-templates',
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
|||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER';
|
||||
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION';
|
||||
|
||||
export interface BillingBalance {
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
billingModel: BillingModel;
|
||||
balance: number;
|
||||
currency: string;
|
||||
warningThreshold: number;
|
||||
|
|
@ -41,19 +39,21 @@ export interface BillingTransaction {
|
|||
export interface BillingSettings {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
billingModel: BillingModel;
|
||||
defaultUserCredit: number;
|
||||
warningThresholdPercent: number;
|
||||
notifyOnWarning: boolean;
|
||||
notifyEmails: string[];
|
||||
autoRechargeEnabled?: boolean;
|
||||
rechargeAmountCHF?: number;
|
||||
rechargeMaxPerMonth?: number;
|
||||
}
|
||||
|
||||
export interface BillingSettingsUpdate {
|
||||
billingModel?: BillingModel;
|
||||
defaultUserCredit?: number;
|
||||
warningThresholdPercent?: number;
|
||||
notifyOnWarning?: boolean;
|
||||
notifyEmails?: string[];
|
||||
autoRechargeEnabled?: boolean;
|
||||
rechargeAmountCHF?: number;
|
||||
rechargeMaxPerMonth?: number;
|
||||
}
|
||||
|
||||
export interface UsageReport {
|
||||
|
|
@ -69,7 +69,6 @@ export interface AccountSummary {
|
|||
id: string;
|
||||
mandateId: string;
|
||||
userId?: string;
|
||||
accountType: string;
|
||||
balance: number;
|
||||
warningThreshold: number;
|
||||
enabled: boolean;
|
||||
|
|
@ -305,10 +304,8 @@ export async function fetchUsersForMandateAdmin(
|
|||
export interface MandateBalance {
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
billingModel: BillingModel;
|
||||
totalBalance: number;
|
||||
userCount: number;
|
||||
defaultUserCredit: number;
|
||||
warningThresholdPercent: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,18 +50,6 @@ export interface CoachingPersona {
|
|||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CoachingDocument {
|
||||
id: string;
|
||||
contextId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
extractedText?: string;
|
||||
summary?: string;
|
||||
fileRef?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CoachingBadge {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
|
@ -110,8 +98,6 @@ export interface CoachingScore {
|
|||
export interface CoachingUserProfile {
|
||||
id: string;
|
||||
userId: string;
|
||||
preferredLanguage: string;
|
||||
preferredVoice?: string;
|
||||
dailyReminderTime?: string;
|
||||
dailyReminderEnabled: boolean;
|
||||
emailSummaryEnabled: boolean;
|
||||
|
|
@ -494,27 +480,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
|
|||
return data.profile;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Voice API
|
||||
// ============================================================================
|
||||
|
||||
export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' });
|
||||
return data.languages || [];
|
||||
}
|
||||
|
||||
export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } });
|
||||
return data.voices || [];
|
||||
}
|
||||
|
||||
export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
text?: string; language?: string; voiceId?: string;
|
||||
}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Persona API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
|
@ -535,42 +500,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId:
|
|||
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
||||
export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingDocument[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' });
|
||||
return data.documents || [];
|
||||
}
|
||||
|
||||
export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise<CoachingDocument> {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
||||
const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
||||
if (pathMatch) {
|
||||
headers['X-Mandate-Id'] = pathMatch[1];
|
||||
headers['X-Instance-Id'] = pathMatch[3];
|
||||
}
|
||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' });
|
||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.document;
|
||||
}
|
||||
|
||||
export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Badge API (Iteration 2)
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export interface Prompt {
|
|||
mandateId: string;
|
||||
content: string;
|
||||
name: string;
|
||||
_createdBy?: string;
|
||||
sysCreatedBy?: string;
|
||||
_hideDelete?: boolean;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ export interface RealEstateProject {
|
|||
featureInstanceId?: string;
|
||||
perimeter?: any;
|
||||
parzellen?: RealEstateParcel[];
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +38,8 @@ export interface RealEstateParcel {
|
|||
plz?: string;
|
||||
perimeter?: any;
|
||||
bauzone?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,21 @@
|
|||
|
||||
import api from '../api';
|
||||
|
||||
export interface StoreFeatureInstance {
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface StoreFeature {
|
||||
featureCode: string;
|
||||
label: Record<string, string>;
|
||||
icon: string;
|
||||
description: Record<string, string>;
|
||||
isActive: boolean;
|
||||
instances: StoreFeatureInstance[];
|
||||
canActivate: boolean;
|
||||
instanceId: string | null;
|
||||
}
|
||||
|
||||
export interface StoreActivateResponse {
|
||||
|
|
@ -31,17 +38,44 @@ export interface StoreDeactivateResponse {
|
|||
deactivated: boolean;
|
||||
}
|
||||
|
||||
export interface UserMandate {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: string | null;
|
||||
status: string | null;
|
||||
maxDataVolumeMB: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
budgetAiCHF: number | null;
|
||||
currentFeatureInstances: number;
|
||||
trialEndsAt: string | null;
|
||||
}
|
||||
|
||||
export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
|
||||
const response = await api.get<StoreFeature[]>('/api/store/features');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function activateStoreFeature(featureCode: string): Promise<StoreActivateResponse> {
|
||||
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode });
|
||||
export async function fetchUserMandates(): Promise<UserMandate[]> {
|
||||
const response = await api.get<UserMandate[]>('/api/store/mandates');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deactivateStoreFeature(featureCode: string): Promise<StoreDeactivateResponse> {
|
||||
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode });
|
||||
export async function fetchSubscriptionInfo(mandateId?: string): Promise<SubscriptionInfo> {
|
||||
const params = mandateId ? { mandateId } : {};
|
||||
const response = await api.get<SubscriptionInfo>('/api/store/subscription-info', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise<StoreActivateResponse> {
|
||||
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode, mandateId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise<StoreDeactivateResponse> {
|
||||
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode, mandateId, instanceId });
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface SubscriptionPlan {
|
|||
autoRenew: boolean;
|
||||
maxUsers: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
maxDataVolumeMB?: number | null;
|
||||
budgetAiCHF?: number;
|
||||
trialDays: number | null;
|
||||
successorPlanKey: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ export interface TrusteeOrganisation {
|
|||
label: string;
|
||||
enabled: boolean;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysModifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -29,10 +29,10 @@ export interface TrusteeRole {
|
|||
id: string;
|
||||
desc: string;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysModifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -43,10 +43,10 @@ export interface TrusteeAccess {
|
|||
userId: string;
|
||||
contractId?: string | null;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysModifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -56,10 +56,10 @@ export interface TrusteeContract {
|
|||
label: string;
|
||||
enabled: boolean;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysModifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -71,10 +71,10 @@ export interface TrusteeDocument {
|
|||
documentMimeType: string;
|
||||
documentData?: any;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysModifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -98,10 +98,10 @@ export interface TrusteePosition {
|
|||
costCenter?: string;
|
||||
bookingReference?: string;
|
||||
mandateId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
_createdBy?: string;
|
||||
_modifiedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedBy?: string;
|
||||
sysModifiedBy?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
@ -696,8 +696,8 @@ export interface TrusteePositionDocument {
|
|||
documentId: string;
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
_createdAt?: number;
|
||||
_modifiedAt?: number;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedAt?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from '../nodes/runtime/workflowStartSync';
|
||||
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
|
@ -55,6 +56,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
initialWorkflowId,
|
||||
}) => {
|
||||
const { request } = useApiRequest();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -140,8 +142,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
} else {
|
||||
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
|
||||
const created = await createWorkflow(request, instanceId, { label, graph, invocations });
|
||||
const label = await promptInput('Workflow-Name:', {
|
||||
title: 'Workflow speichern',
|
||||
defaultValue: 'Neuer Workflow',
|
||||
placeholder: 'Name des Workflows',
|
||||
});
|
||||
if (!label) {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const created = await createWorkflow(request, instanceId, {
|
||||
label: label.trim() || 'Neuer Workflow',
|
||||
graph,
|
||||
invocations,
|
||||
});
|
||||
setCurrentWorkflowId(created.id);
|
||||
if (created.invocations?.length) setInvocations(created.invocations);
|
||||
setWorkflows((prev) => [...prev, created]);
|
||||
|
|
@ -152,7 +166,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (workflowId: string) => {
|
||||
|
|
@ -436,7 +450,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptDialog />
|
||||
<WorkflowConfigurationModal
|
||||
open={workflowSettingsOpen}
|
||||
onClose={() => setWorkflowSettingsOpen(false)}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|||
import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
|
||||
import { Popup } from '../UiComponents/Popup';
|
||||
import { ActionsPanel } from '../ActionsPanel';
|
||||
import { ProviderMultiSelect } from '../ProviderSelector';
|
||||
import { ProviderMultiSelect, _defaultProviderSelection, _migrateFromLegacy, _toBackendProviders } from '../ProviderSelector';
|
||||
import type { ProviderSelection } from '../ProviderSelector';
|
||||
import { useBilling } from '../../hooks/useBilling';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { useWorkflowActions } from '../../hooks/useAutomations';
|
||||
|
|
@ -374,7 +376,8 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
|||
const [label, setLabel] = useState('');
|
||||
const [schedule, setSchedule] = useState('0 22 * * *');
|
||||
const [active, setActive] = useState(false);
|
||||
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
||||
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
||||
const { allowedProviders: billingProviders } = useBilling();
|
||||
|
||||
// Template multilingual fields
|
||||
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
|
||||
|
|
@ -537,7 +540,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
|||
setLabel(def.label || '');
|
||||
setSchedule(def.schedule || '0 22 * * *');
|
||||
setActive(def.active ?? false);
|
||||
setAllowedProviders(def.allowedProviders || []);
|
||||
setProviderSelection(_migrateFromLegacy(def.allowedProviders || []));
|
||||
}
|
||||
|
||||
// Extract template JSON
|
||||
|
|
@ -693,7 +696,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
|||
active,
|
||||
template: templateJson,
|
||||
placeholders,
|
||||
allowedProviders
|
||||
allowedProviders: _toBackendProviders(providerSelection, billingProviders),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -709,7 +712,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
|||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
|
||||
}, [label, schedule, active, providerSelection, billingProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
|
||||
|
||||
// Computed values
|
||||
const editorTitle = title || (mode === 'template'
|
||||
|
|
@ -864,12 +867,12 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
|||
{/* Allowed AI Providers */}
|
||||
<div className={styles.formGroup}>
|
||||
<ProviderMultiSelect
|
||||
selectedProviders={allowedProviders}
|
||||
onChange={setAllowedProviders}
|
||||
selection={providerSelection}
|
||||
onChange={setProviderSelection}
|
||||
label="Erlaubte AI-Provider"
|
||||
/>
|
||||
<p className={styles.formHint}>
|
||||
Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt.
|
||||
Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,25 @@
|
|||
font-size: 10px;
|
||||
color: var(--color-text-secondary, #999);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scopeIcons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rightZone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rightZone .actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.rootActions {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
||||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import styles from './FolderTree.module.css';
|
||||
|
||||
/* ── Public types ──────────────────────────────────────────────────────── */
|
||||
|
|
@ -30,6 +31,8 @@ export interface FileNode {
|
|||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
folderId?: string | null;
|
||||
scope?: string;
|
||||
neutralize?: boolean;
|
||||
}
|
||||
|
||||
export interface TreeItem {
|
||||
|
|
@ -62,6 +65,8 @@ export interface FolderTreeProps {
|
|||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
||||
|
|
@ -146,6 +151,22 @@ function _fileIcon(mime?: string): string {
|
|||
|
||||
/* ── Selection context threaded through the tree ──────────────────────── */
|
||||
|
||||
const _SCOPE_ICONS: Record<string, string> = {
|
||||
personal: '\uD83D\uDC64',
|
||||
featureInstance: '\uD83D\uDC65',
|
||||
mandate: '\uD83C\uDFE2',
|
||||
global: '\uD83C\uDF10',
|
||||
};
|
||||
|
||||
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
|
||||
|
||||
const _SCOPE_LABELS: Record<string, string> = {
|
||||
personal: 'Persönlich',
|
||||
featureInstance: 'Instanz',
|
||||
mandate: 'Mandant',
|
||||
global: 'Global',
|
||||
};
|
||||
|
||||
interface SelectionCtx {
|
||||
selectedItemIds: Set<string>;
|
||||
selectedFileIds: string[];
|
||||
|
|
@ -156,6 +177,8 @@ interface SelectionCtx {
|
|||
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||
}
|
||||
|
||||
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||||
|
|
@ -227,39 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
|||
) : (
|
||||
<span className={styles.folderName}>{file.fileName}</span>
|
||||
)}
|
||||
{!renaming && file.fileSize != null && (
|
||||
<span className={styles.fileSize}>
|
||||
{(file.fileSize / 1024).toFixed(0)}K
|
||||
</span>
|
||||
)}
|
||||
{!renaming && (
|
||||
<span className={styles.actions}>
|
||||
{sel.onRenameFile && !multiSelected && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||
<FaPen />
|
||||
</button>
|
||||
)}
|
||||
{multiSelected && isSelected ? (
|
||||
<>
|
||||
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
||||
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
||||
<FaTrash />
|
||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
||||
<FaTrash />
|
||||
<span className={styles.rightZone}>
|
||||
<span className={styles.actions}>
|
||||
{sel.onRenameFile && !multiSelected && (
|
||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||
<FaPen />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{multiSelected && isSelected ? (
|
||||
<>
|
||||
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
||||
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
||||
<FaTrash />
|
||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
||||
<FaTrash />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
{file.fileSize != null && (
|
||||
<span className={styles.fileSize}>
|
||||
{(file.fileSize / 1024).toFixed(0)}K
|
||||
</span>
|
||||
)}
|
||||
{file.scope != null && (
|
||||
<span className={styles.scopeIcons}>
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!sel.onScopeChange) return;
|
||||
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
|
||||
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
||||
sel.onScopeChange(file.id, next);
|
||||
}}
|
||||
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.actionBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
|
||||
}}
|
||||
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
|
||||
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
|
||||
>
|
||||
{'\uD83D\uDD12'}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -277,6 +331,7 @@ interface TreeNodeProps {
|
|||
showFiles: boolean;
|
||||
filesByFolder: Map<string, FileNode[]>;
|
||||
sel: SelectionCtx;
|
||||
promptFolderName: (message: string) => Promise<string | null>;
|
||||
onToggle: (id: string) => void;
|
||||
onSelect: (id: string | null) => void;
|
||||
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
||||
|
|
@ -291,6 +346,7 @@ interface TreeNodeProps {
|
|||
|
||||
function _TreeNode({
|
||||
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
||||
promptFolderName,
|
||||
onToggle, onSelect,
|
||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||
onDownloadFolder,
|
||||
|
|
@ -321,12 +377,12 @@ function _TreeNode({
|
|||
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!onCreateFolder) return;
|
||||
const name = prompt('Neuer Ordnername:');
|
||||
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||
if (name?.trim()) {
|
||||
await onCreateFolder(name.trim(), node.id);
|
||||
if (!expandedIds.has(node.id)) onToggle(node.id);
|
||||
}
|
||||
}, [onCreateFolder, node.id, expandedIds, onToggle]);
|
||||
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]);
|
||||
|
||||
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -488,6 +544,7 @@ function _TreeNode({
|
|||
showFiles={showFiles}
|
||||
filesByFolder={filesByFolder}
|
||||
sel={sel}
|
||||
promptFolderName={promptFolderName}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onCreateFolder={onCreateFolder}
|
||||
|
|
@ -517,11 +574,13 @@ export default function FolderTree({
|
|||
expandedIds: externalExpandedIds, onToggleExpand,
|
||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||
onScopeChange, onNeutralizeToggle,
|
||||
}: FolderTreeProps) {
|
||||
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [rootDropOver, setRootDropOver] = useState(false);
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||||
const lastClickedIdRef = useRef<string | null>(null);
|
||||
const { prompt: promptFolderName, PromptDialog } = usePrompt();
|
||||
|
||||
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
||||
|
||||
|
|
@ -634,8 +693,10 @@ export default function FolderTree({
|
|||
onDeleteFile,
|
||||
onDeleteFiles,
|
||||
onDeleteFolders,
|
||||
onScopeChange,
|
||||
onNeutralizeToggle,
|
||||
};
|
||||
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]);
|
||||
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
|
||||
|
||||
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -699,7 +760,7 @@ export default function FolderTree({
|
|||
className={styles.actionBtn}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const name = prompt('Neuer Ordnername:');
|
||||
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
||||
}}
|
||||
title="Neuer Ordner"
|
||||
|
|
@ -720,6 +781,7 @@ export default function FolderTree({
|
|||
showFiles={showFiles}
|
||||
filesByFolder={filesByFolder}
|
||||
sel={sel}
|
||||
promptFolderName={promptFolderName}
|
||||
onToggle={_handleToggle}
|
||||
onSelect={onSelect}
|
||||
onCreateFolder={onCreateFolder}
|
||||
|
|
@ -736,6 +798,7 @@ export default function FolderTree({
|
|||
<_FileItem key={file.id} file={file} sel={sel} />
|
||||
))}
|
||||
</div>
|
||||
<PromptDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,6 +282,27 @@
|
|||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Rename button (inline, hover-visible via TreeNavigation nodeActions) */
|
||||
.renameButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary, #888);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.renameButton:hover {
|
||||
color: var(--primary-color, #2563eb);
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
/* Dark Theme */
|
||||
:global(.dark-theme) .separator {
|
||||
background: var(--border-dark, #333);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
* - Users, Mandates, Roles, ...
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
import type {
|
||||
DynamicBlock,
|
||||
|
|
@ -31,8 +31,11 @@ import type {
|
|||
FeatureView
|
||||
} from '../../hooks/useNavigation';
|
||||
import { getPageIcon } from '../../config/pageRegistry';
|
||||
import { FaSpinner } from 'react-icons/fa';
|
||||
import { FaSpinner, FaPen } from 'react-icons/fa';
|
||||
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
||||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import api from '../../api';
|
||||
import styles from './MandateNavigation.module.css';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -84,16 +87,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
|||
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
||||
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
|
||||
* Shows the feature icon next to the instance name for visual distinction.
|
||||
* If user is instance admin, a rename icon appears on hover.
|
||||
*/
|
||||
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
|
||||
function featureInstanceToTreeNode(
|
||||
instance: FeatureInstance,
|
||||
featureUiComponent: string,
|
||||
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||
): TreeNodeItem {
|
||||
const children = instance.views.map(featureViewToTreeNode);
|
||||
const renameAction = instance.isAdmin && onRename ? (
|
||||
<button
|
||||
className={styles.renameButton}
|
||||
title="Umbenennen"
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
|
||||
>
|
||||
<FaPen size={10} />
|
||||
</button>
|
||||
) : undefined;
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
label: instance.uiLabel,
|
||||
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
|
||||
icon: getPageIcon(featureUiComponent),
|
||||
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
||||
children,
|
||||
defaultExpanded: false,
|
||||
actions: renameAction,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -106,16 +125,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent
|
|||
* Before: Mandate → Feature → Instance → Views
|
||||
* Now: Mandate → Instance (with feature icon) → Views
|
||||
*/
|
||||
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
|
||||
function navigationMandateToTreeNode(
|
||||
mandate: NavigationMandate,
|
||||
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||
): TreeNodeItem | null {
|
||||
if (mandate.features.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Flatten: collect all instances from all features directly under mandate
|
||||
const instanceNodes: TreeNodeItem[] = [];
|
||||
for (const feature of mandate.features) {
|
||||
for (const instance of feature.instances) {
|
||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
|
||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -134,9 +155,12 @@ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem |
|
|||
/**
|
||||
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
|
||||
*/
|
||||
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] {
|
||||
function dynamicBlockToTreeNodes(
|
||||
block: DynamicBlock,
|
||||
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||
): TreeNodeItem[] {
|
||||
return block.mandates
|
||||
.map(navigationMandateToTreeNode)
|
||||
.map((m) => navigationMandateToTreeNode(m, onRename))
|
||||
.filter((node): node is TreeNodeItem => node !== null);
|
||||
}
|
||||
|
||||
|
|
@ -169,18 +193,24 @@ const EmptyState: React.FC = () => (
|
|||
// =============================================================================
|
||||
|
||||
export const MandateNavigation: React.FC = () => {
|
||||
// Fetch navigation from new API (blocks structure, already filtered by permissions)
|
||||
const { blocks, loading } = useNavigation('de');
|
||||
|
||||
// Build navigation items from blocks
|
||||
// Groups static items into collapsible containers:
|
||||
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
|
||||
// - "Administration": admin items, possibly with subgroups
|
||||
// - Dynamic block (mandates) renders between them
|
||||
const { blocks, loading, refresh } = useNavigation('de');
|
||||
const { prompt, PromptDialog } = usePrompt();
|
||||
const { showWarning } = useToast();
|
||||
|
||||
const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
|
||||
const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
|
||||
if (!newLabel || newLabel.trim() === currentLabel) return;
|
||||
try {
|
||||
await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() });
|
||||
refresh();
|
||||
} catch (err: any) {
|
||||
showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message));
|
||||
}
|
||||
}, [refresh, prompt, showWarning]);
|
||||
|
||||
const navigationItems: TreeItem[] = useMemo(() => {
|
||||
const items: TreeItem[] = [];
|
||||
|
||||
// Collect static items by category
|
||||
const meineSichtItems: NavigationItem[] = [];
|
||||
let adminItems: NavigationItem[] = [];
|
||||
let adminSubgroups: NavSubgroup[] = [];
|
||||
|
|
@ -199,15 +229,13 @@ export const MandateNavigation: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// "Meine Sicht" - collapsible container for user-facing pages
|
||||
if (meineSichtItems.length > 0) {
|
||||
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
|
||||
}
|
||||
|
||||
// Dynamic block: mandates with feature instances
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'dynamic') {
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block);
|
||||
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename);
|
||||
if (mandateNodes.length > 0) {
|
||||
if (items.length > 0) items.push({ type: 'separator' });
|
||||
items.push(...mandateNodes);
|
||||
|
|
@ -215,7 +243,6 @@ export const MandateNavigation: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// "Administration" - collapsible container for admin pages (with subgroup support)
|
||||
if (adminSubgroups.length > 0) {
|
||||
if (items.length > 0) items.push({ type: 'separator' });
|
||||
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
|
||||
|
|
@ -236,7 +263,7 @@ export const MandateNavigation: React.FC = () => {
|
|||
}
|
||||
|
||||
return items;
|
||||
}, [blocks]);
|
||||
}, [blocks, _handleRename]);
|
||||
|
||||
// Check if user has any navigation (static or dynamic)
|
||||
const hasNavigation = blocks.length > 0;
|
||||
|
|
@ -260,6 +287,7 @@ export const MandateNavigation: React.FC = () => {
|
|||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
<PromptDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -257,6 +257,22 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* NODE ACTIONS (hover-reveal inline icons) */
|
||||
/* ============================================ */
|
||||
|
||||
.nodeActions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.treeNode:hover .nodeActions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ============================================ */
|
||||
/* DARK THEME */
|
||||
/* ============================================ */
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export interface TreeNodeItem {
|
|||
level?: number;
|
||||
/** Data attribute for testing/identification */
|
||||
dataId?: string;
|
||||
/** Inline action element rendered at the end of the row (e.g. rename icon) */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export interface TreeSectionItem {
|
||||
|
|
@ -219,6 +221,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
|||
{node.badge}
|
||||
</span>
|
||||
)}
|
||||
{node.actions && (
|
||||
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
|
||||
{node.actions}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import React, { useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { NotificationBell } from '../NotificationBell';
|
||||
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
|
||||
import styles from './UserSection.module.css';
|
||||
|
||||
export const UserSection: React.FC = () => {
|
||||
|
|
@ -16,6 +17,7 @@ export const UserSection: React.FC = () => {
|
|||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showLegalModal, setShowLegalModal] = useState(false);
|
||||
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
|
|
@ -41,6 +43,13 @@ export const UserSection: React.FC = () => {
|
|||
setShowLegalModal(true);
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
const handleOnboarding = () => {
|
||||
_showOnboarding();
|
||||
setOnboardingHidden(false);
|
||||
navigate('/', { state: { showOnboarding: Date.now() } });
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
|
|
@ -61,7 +70,7 @@ export const UserSection: React.FC = () => {
|
|||
|
||||
<button
|
||||
className={styles.userButton}
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
|
||||
aria-expanded={showMenu}
|
||||
>
|
||||
<div className={styles.avatar}>
|
||||
|
|
@ -94,6 +103,16 @@ export const UserSection: React.FC = () => {
|
|||
Einstellungen
|
||||
</button>
|
||||
|
||||
{onboardingHidden && (
|
||||
<button
|
||||
className={styles.menuItem}
|
||||
onClick={handleOnboarding}
|
||||
>
|
||||
<span className={styles.menuIcon}>{'\uD83E\uDDED'}</span>
|
||||
Onboarding-Assistent
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={styles.menuItem}
|
||||
onClick={handleLegal}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,11 @@ const typeIcons: Record<string, React.ReactNode> = {
|
|||
mention: <FaExclamationTriangle />
|
||||
};
|
||||
|
||||
// Format timestamp to relative time
|
||||
// Format timestamp to relative time (Unix seconds)
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||
return '';
|
||||
}
|
||||
const now = Date.now() / 1000;
|
||||
const diff = now - timestamp;
|
||||
|
||||
|
|
@ -29,6 +32,9 @@ function formatRelativeTime(timestamp: number): string {
|
|||
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
|
|
|
|||
311
src/components/OnboardingAssistant.tsx
Normal file
311
src/components/OnboardingAssistant.tsx
Normal 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;
|
||||
122
src/components/OnboardingWizard.tsx
Normal file
122
src/components/OnboardingWizard.tsx
Normal 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;
|
||||
|
|
@ -53,17 +53,17 @@
|
|||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-color, #2d2d2d);
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--surface-color, #ffffff);
|
||||
color: var(--text-secondary, #666666);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.triggerButton:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #3a3a3a);
|
||||
color: var(--text-primary, #fff);
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.triggerButton:disabled {
|
||||
|
|
@ -83,20 +83,20 @@
|
|||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
padding: 8px;
|
||||
background: var(--surface-color, #2d2d2d);
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
background: var(--surface-color, #ffffff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.dropdownHeader {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--text-secondary, #666666);
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.selectActions {
|
||||
|
|
@ -108,18 +108,18 @@
|
|||
.actionButton {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #3a3a3a);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
color: var(--text-secondary, #666666);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.actionButton:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #3a3a3a);
|
||||
color: var(--text-primary, #fff);
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.actionButton.active {
|
||||
|
|
@ -138,7 +138,7 @@
|
|||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-radius: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
|
|
@ -151,12 +151,13 @@
|
|||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.checkboxItem:hover {
|
||||
background: var(--bg-hover, #3a3a3a);
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.checkboxItem.disabled {
|
||||
|
|
@ -177,12 +178,12 @@
|
|||
|
||||
.providerName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #666);
|
||||
color: var(--text-tertiary, #888888);
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
|
@ -192,10 +193,24 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--text-secondary, #888);
|
||||
color: var(--text-secondary, #666666);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Dark theme: list hover stays a light lift, not a black wash */
|
||||
:global(.dark-theme) .checkboxItem:hover {
|
||||
background: var(--hover-bg, rgba(255, 255, 255, 0.08));
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .checkboxItem {
|
||||
color: var(--text-primary, #e5e7eb);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .dropdownContent {
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
PROVIDER BADGES
|
||||
============================================================================ */
|
||||
|
|
|
|||
|
|
@ -1,19 +1,80 @@
|
|||
/**
|
||||
* ProviderSelector Component
|
||||
*
|
||||
*
|
||||
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
|
||||
* Kann im AI Workspace und Automation Editor verwendet werden.
|
||||
*
|
||||
* Features:
|
||||
* - Dropdown für Einzelauswahl
|
||||
* - Checkbox-Liste für Mehrfachauswahl
|
||||
* - Lädt verfügbare Provider aus dem Billing-System
|
||||
*
|
||||
* Selektionsmodell:
|
||||
* ProviderSelection { include: string[], exclude: string[] }
|
||||
* - include(["ALL"]), exclude([]) → alle verfügbaren Provider (dynamisch)
|
||||
* - include(["ALL"]), exclude(["private"]) → alle ausser "private" (dynamisch)
|
||||
* - include(["anthropic"]), exclude([]) → nur Anthropic
|
||||
* - include([]), exclude([]) → keiner ausgewählt
|
||||
*
|
||||
* resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useBilling } from '../../hooks/useBilling';
|
||||
import styles from './ProviderSelector.module.css';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & HELPERS
|
||||
// ============================================================================
|
||||
|
||||
export const PROVIDER_ALL = 'ALL';
|
||||
|
||||
export interface ProviderSelection {
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
}
|
||||
|
||||
export function _defaultProviderSelection(): ProviderSelection {
|
||||
return { include: [PROVIDER_ALL], exclude: [] };
|
||||
}
|
||||
|
||||
export function _resolveProviders(
|
||||
selection: ProviderSelection,
|
||||
allowedProviders: string[],
|
||||
): string[] {
|
||||
if (selection.include.includes(PROVIDER_ALL)) {
|
||||
return allowedProviders.filter((p) => !selection.exclude.includes(p));
|
||||
}
|
||||
return selection.include.filter((p) => allowedProviders.includes(p));
|
||||
}
|
||||
|
||||
export function _isAllSelected(selection: ProviderSelection): boolean {
|
||||
return selection.include.includes(PROVIDER_ALL) && selection.exclude.length === 0;
|
||||
}
|
||||
|
||||
export function _isNoneSelected(
|
||||
selection: ProviderSelection,
|
||||
allowedProviders: string[],
|
||||
): boolean {
|
||||
return _resolveProviders(selection, allowedProviders).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy string[] (old model) to ProviderSelection.
|
||||
* [] → ALL, [...ids] → include those ids.
|
||||
*/
|
||||
export function _migrateFromLegacy(providers: string[]): ProviderSelection {
|
||||
if (providers.length === 0) return _defaultProviderSelection();
|
||||
return { include: providers, exclude: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ProviderSelection to flat list for backend API calls.
|
||||
* Returns [] when ALL are selected (= no restriction / legacy behaviour).
|
||||
*/
|
||||
export function _toBackendProviders(
|
||||
selection: ProviderSelection,
|
||||
allowedProviders: string[],
|
||||
): string[] {
|
||||
if (_isAllSelected(selection)) return [];
|
||||
return _resolveProviders(selection, allowedProviders);
|
||||
}
|
||||
|
||||
// Provider display names
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
anthropic: 'Anthropic (Claude)',
|
||||
|
|
@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record<string, string> = {
|
|||
internal: 'Internal',
|
||||
};
|
||||
|
||||
// Provider icons (emojis for simplicity)
|
||||
const PROVIDER_ICONS: Record<string, string> = {
|
||||
anthropic: '🤖',
|
||||
openai: '💬',
|
||||
|
|
@ -58,20 +118,20 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
|
|||
showLabel = true,
|
||||
}) => {
|
||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (allowedProviders.length === 0 && !loading) {
|
||||
loadAllowedProviders();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const providerOptions = useMemo(() => {
|
||||
return allowedProviders.map((provider) => ({
|
||||
value: provider,
|
||||
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
|
||||
}));
|
||||
}, [allowedProviders]);
|
||||
|
||||
|
||||
return (
|
||||
<div className={`${styles.providerSelect} ${className || ''}`}>
|
||||
{showLabel && <label className={styles.label}>{label}</label>}
|
||||
|
|
@ -93,12 +153,12 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
|
|||
};
|
||||
|
||||
// ============================================================================
|
||||
// MULTI SELECT COMPONENT (Checkbox List)
|
||||
// MULTI SELECT COMPONENT (Checkbox List) — include / exclude model
|
||||
// ============================================================================
|
||||
|
||||
interface ProviderMultiSelectProps {
|
||||
selectedProviders: string[];
|
||||
onChange: (providers: string[]) => void;
|
||||
selection: ProviderSelection;
|
||||
onChange: (selection: ProviderSelection) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
|
|
@ -108,7 +168,7 @@ interface ProviderMultiSelectProps {
|
|||
}
|
||||
|
||||
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||
selectedProviders,
|
||||
selection,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
|
|
@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (allowedProviders.length === 0 && !loading) {
|
||||
loadAllowedProviders();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Apply default exclusions when providers first load
|
||||
|
||||
// Apply default exclusions once when providers first load
|
||||
useEffect(() => {
|
||||
if (
|
||||
!initialExcludeApplied &&
|
||||
allowedProviders.length > 0 &&
|
||||
excludeByDefault.length > 0 &&
|
||||
selectedProviders.length === 0
|
||||
_isAllSelected(selection)
|
||||
) {
|
||||
const initialSelection = allowedProviders.filter(
|
||||
(p) => !excludeByDefault.includes(p)
|
||||
);
|
||||
// Only apply if there's actually something to exclude
|
||||
if (initialSelection.length < allowedProviders.length) {
|
||||
onChange(initialSelection);
|
||||
}
|
||||
onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] });
|
||||
setInitialExcludeApplied(true);
|
||||
}
|
||||
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]);
|
||||
|
||||
// Click outside handler
|
||||
const handleClickOutside = useCallback((event: MouseEvent) => {
|
||||
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
|
||||
|
||||
const _handleClickOutside = useCallback((event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('mousedown', _handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
||||
}
|
||||
}, [isExpanded, handleClickOutside]);
|
||||
|
||||
// Effective selection: empty array = all providers active (no restriction)
|
||||
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
|
||||
|
||||
// "Alle" is active when no restriction is set (empty array) OR all explicitly selected
|
||||
const isAllSelected = selectedProviders.length === 0 ||
|
||||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
|
||||
|
||||
const handleToggle = (provider: string) => {
|
||||
if (selectedProviders.length === 0) {
|
||||
// Currently "all active" (no restriction) -> make explicit: all except the toggled one
|
||||
onChange(allowedProviders.filter((p) => p !== provider));
|
||||
} else if (selectedProviders.includes(provider)) {
|
||||
// Deactivate: remove from selection
|
||||
const remaining = selectedProviders.filter((p) => p !== provider);
|
||||
// If removing leaves all others selected, reset to [] (= all, no restriction)
|
||||
if (remaining.length === allowedProviders.length) {
|
||||
onChange([]);
|
||||
}, [isExpanded, _handleClickOutside]);
|
||||
|
||||
const effectiveSelection = useMemo(
|
||||
() => _resolveProviders(selection, allowedProviders),
|
||||
[selection, allowedProviders],
|
||||
);
|
||||
|
||||
const allSelected = _isAllSelected(selection);
|
||||
const noneSelected = effectiveSelection.length === 0;
|
||||
|
||||
const _handleToggle = (provider: string) => {
|
||||
const isChecked = effectiveSelection.includes(provider);
|
||||
|
||||
if (selection.include.includes(PROVIDER_ALL)) {
|
||||
// Currently ALL-based: toggle modifies exclude list
|
||||
if (isChecked) {
|
||||
onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] });
|
||||
} else {
|
||||
onChange(remaining);
|
||||
const nextExclude = selection.exclude.filter((p) => p !== provider);
|
||||
onChange({ include: [PROVIDER_ALL], exclude: nextExclude });
|
||||
}
|
||||
} else {
|
||||
// Activate: add to selection
|
||||
const updated = [...selectedProviders, provider];
|
||||
// If all are now selected, reset to [] (= all, no restriction)
|
||||
if (updated.length === allowedProviders.length) {
|
||||
onChange([]);
|
||||
// Explicit include list
|
||||
if (isChecked) {
|
||||
onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] });
|
||||
} else {
|
||||
onChange(updated);
|
||||
const nextInclude = [...selection.include, provider];
|
||||
if (nextInclude.length === allowedProviders.length) {
|
||||
onChange({ include: [PROVIDER_ALL], exclude: [] });
|
||||
} else {
|
||||
onChange({ include: nextInclude, exclude: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onChange([]); // Empty = all active, no restriction
|
||||
|
||||
const _handleSelectAll = () => {
|
||||
onChange({ include: [PROVIDER_ALL], exclude: [] });
|
||||
};
|
||||
|
||||
// Summary icon for button
|
||||
|
||||
const summaryIcon = useMemo(() => {
|
||||
if (noneSelected) return '⊘';
|
||||
if (effectiveSelection.length === 1) {
|
||||
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
|
||||
}
|
||||
return '🤖';
|
||||
}, [effectiveSelection]);
|
||||
|
||||
return '⚡';
|
||||
}, [effectiveSelection, noneSelected]);
|
||||
|
||||
const summaryHint = useMemo(() => {
|
||||
if (noneSelected) return 'Kein Provider ausgewählt';
|
||||
if (allSelected) return 'Alle Provider aktiv (dynamisch)';
|
||||
if (selection.include.includes(PROVIDER_ALL)) {
|
||||
return `Alle ausser ${selection.exclude.length} Provider`;
|
||||
}
|
||||
return `${effectiveSelection.length} von ${allowedProviders.length} Provider`;
|
||||
}, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||
>
|
||||
{/* Trigger Button - styled like iconButton */}
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
className={styles.triggerButton}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
|
|
@ -220,36 +283,35 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
>
|
||||
<span className={styles.buttonIcon}>{summaryIcon}</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Content */}
|
||||
|
||||
{isExpanded && (
|
||||
<div className={styles.dropdownContent}>
|
||||
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
|
||||
|
||||
|
||||
<div className={styles.selectActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
<button
|
||||
type="button"
|
||||
onClick={_handleSelectAll}
|
||||
disabled={disabled}
|
||||
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
|
||||
className={`${styles.actionButton} ${allSelected ? styles.active : ''}`}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>Lade...</div>
|
||||
) : (
|
||||
<div className={styles.checkboxList}>
|
||||
{allowedProviders.map((provider) => (
|
||||
<label
|
||||
key={provider}
|
||||
<label
|
||||
key={provider}
|
||||
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={effectiveSelection.includes(provider)}
|
||||
onChange={() => handleToggle(provider)}
|
||||
onChange={() => _handleToggle(provider)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
|
||||
|
|
@ -260,12 +322,8 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAllSelected && !loading && (
|
||||
<div className={styles.hint}>
|
||||
Alle Provider aktiv (kein Filter)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.hint}>{summaryHint}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -288,7 +346,7 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
|
|||
if (providers.length === 0) {
|
||||
return <span className={styles.allProviders}>Alle Provider</span>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={`${styles.providerBadges} ${className || ''}`}>
|
||||
{providers.map((provider) => (
|
||||
|
|
@ -300,5 +358,4 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// Default export
|
||||
export default ProviderSelect;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,19 @@
|
|||
export {
|
||||
ProviderSelect,
|
||||
ProviderMultiSelect,
|
||||
ProviderBadges
|
||||
ProviderBadges,
|
||||
} from './ProviderSelector';
|
||||
|
||||
export {
|
||||
PROVIDER_ALL,
|
||||
_defaultProviderSelection,
|
||||
_resolveProviders,
|
||||
_isAllSelected,
|
||||
_isNoneSelected,
|
||||
_migrateFromLegacy,
|
||||
_toBackendProviders,
|
||||
} from './ProviderSelector';
|
||||
|
||||
export type { ProviderSelection } from './ProviderSelector';
|
||||
|
||||
export { default } from './ProviderSelector';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
||||
import { WorkflowFile } from '../../../hooks/usePlayground';
|
||||
import styles from './ConnectedFilesList.module.css';
|
||||
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||
|
||||
export interface ConnectedFilesListActionButton {
|
||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
|
||||
|
|
@ -240,7 +241,7 @@ export function ConnectedFilesList({
|
|||
</div>
|
||||
<div className={styles.fileMeta}>
|
||||
<span className={styles.fileSize}>
|
||||
{formatFileSize(file.fileSize)}
|
||||
{formatBinaryDataSizeBytes(file.fileSize)}
|
||||
</span>
|
||||
{file.source && (
|
||||
<span className={styles.fileSource}>
|
||||
|
|
@ -371,13 +372,5 @@ export function ConnectedFilesList({
|
|||
);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export default ConnectedFilesList;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
* Utility functions for message formatting and styling
|
||||
*/
|
||||
|
||||
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||
|
||||
/**
|
||||
* Formats a timestamp to a readable date/time string
|
||||
* Handles both Unix timestamps in seconds and milliseconds
|
||||
|
|
@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats file size to human-readable format
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
/** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */
|
||||
export const formatFileSize = formatBinaryDataSizeBytes;
|
||||
|
||||
/**
|
||||
* Gets status badge color class based on status
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ export interface MessageDocument {
|
|||
taskNumber: number;
|
||||
actionNumber: number;
|
||||
actionId: string;
|
||||
documentName?: string;
|
||||
validationMetadata?: {
|
||||
neutralized?: boolean;
|
||||
skipped?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
313
src/components/UnifiedDataBar/ChatsTab.module.css
Normal file
313
src/components/UnifiedDataBar/ChatsTab.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
444
src/components/UnifiedDataBar/ChatsTab.tsx
Normal file
444
src/components/UnifiedDataBar/ChatsTab.tsx
Normal 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;
|
||||
95
src/components/UnifiedDataBar/FilesTab.module.css
Normal file
95
src/components/UnifiedDataBar/FilesTab.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
337
src/components/UnifiedDataBar/FilesTab.tsx
Normal file
337
src/components/UnifiedDataBar/FilesTab.tsx
Normal 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;
|
||||
11
src/components/UnifiedDataBar/SourcesTab.module.css
Normal file
11
src/components/UnifiedDataBar/SourcesTab.module.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
* └─ Service (Level 2, loaded when connection 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 api from '../../../api';
|
||||
import { getPageIcon } from '../../../config/pageRegistry';
|
||||
import type { DataSource, FeatureDataSource } from './useWorkspace';
|
||||
import type { UdbContext } from './UnifiedDataBar';
|
||||
import api from '../../api';
|
||||
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 {
|
||||
key: string;
|
||||
|
|
@ -27,7 +55,6 @@ interface TreeNode {
|
|||
connectionId: string;
|
||||
service?: string;
|
||||
path?: string;
|
||||
/** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */
|
||||
displayPath?: string;
|
||||
authority?: string;
|
||||
}
|
||||
|
|
@ -58,15 +85,13 @@ interface FeatureTableNode {
|
|||
fields: string[];
|
||||
}
|
||||
|
||||
interface DataSourcePanelProps {
|
||||
instanceId: string;
|
||||
dataSources: DataSource[];
|
||||
featureDataSources: FeatureDataSource[];
|
||||
onRefresh: () => void;
|
||||
onRefreshFeatureDataSources: () => void;
|
||||
/* ─── Props ──────────────────────────────────────────────────────────── */
|
||||
|
||||
interface SourcesTabProps {
|
||||
context: UdbContext;
|
||||
}
|
||||
|
||||
/* ─── Icons ─────────────────────────────────────────────────────────── */
|
||||
/* ─── Icons ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const _AUTHORITY_ICONS: Record<string, string> = {
|
||||
msft: '\uD83D\uDFE6',
|
||||
|
|
@ -113,6 +138,40 @@ function _getSourceIcon(sourceType: string): string {
|
|||
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(
|
||||
prev: MandateGroupNode[],
|
||||
featureInstanceId: string,
|
||||
|
|
@ -137,14 +196,14 @@ function _findFeatureInstanceMeta(
|
|||
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 || '';
|
||||
return pathPart ? `${connLabel} / ${pathPart}` : connLabel;
|
||||
}
|
||||
|
||||
function _featureDataSourceHoverTitle(
|
||||
meta: { mandateLabel: string; instanceLabel: string } | null,
|
||||
fds: FeatureDataSource,
|
||||
fds: UdbFeatureDataSource,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (meta) {
|
||||
|
|
@ -160,24 +219,153 @@ function _featureDataSourceHoverTitle(
|
|||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
/* ─── Component ─────────────────────────────────────────────────────── */
|
||||
/* ─── Data fetching (module-level) ───────────────────────────────────── */
|
||||
|
||||
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||
instanceId,
|
||||
dataSources,
|
||||
featureDataSources,
|
||||
onRefresh,
|
||||
onRefreshFeatureDataSources,
|
||||
}) => {
|
||||
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';
|
||||
}
|
||||
|
||||
/* ─── 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 [loadingRoot, setLoadingRoot] = useState(false);
|
||||
const [addingPath, setAddingPath] = useState<string | null>(null);
|
||||
|
||||
/* ── Feature tree state ── */
|
||||
const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
|
||||
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||||
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
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 ── */
|
||||
const _loadConnections = useCallback(() => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -271,23 +459,23 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
|||
label: node.label,
|
||||
displayPath: node.displayPath || node.label,
|
||||
});
|
||||
onRefresh();
|
||||
_fetchDataSources();
|
||||
} catch (err) {
|
||||
console.error('Failed to add data source:', err);
|
||||
} finally {
|
||||
if (mountedRef.current) setAddingPath(null);
|
||||
}
|
||||
}, [instanceId, onRefresh]);
|
||||
}, [instanceId, _fetchDataSources]);
|
||||
|
||||
/* ── Remove DataSource ── */
|
||||
const _removeDatasource = useCallback(async (dsId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
|
||||
onRefresh();
|
||||
_fetchDataSources();
|
||||
} catch (err) {
|
||||
console.error('Failed to remove data source:', err);
|
||||
}
|
||||
}, [instanceId, onRefresh]);
|
||||
}, [instanceId, _fetchDataSources]);
|
||||
|
||||
/* ── Check if a path is already added ── */
|
||||
const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
|
||||
|
|
@ -296,6 +484,50 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
|||
);
|
||||
}, [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 ── */
|
||||
const _loadFeatureConnections = useCallback(() => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -384,23 +616,23 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
|||
objectKey: table.objectKey,
|
||||
label: table.label?.en || table.label?.de || table.tableName,
|
||||
});
|
||||
onRefreshFeatureDataSources();
|
||||
_fetchFeatureDataSources();
|
||||
} catch (err) {
|
||||
console.error('Failed to add feature data source:', err);
|
||||
} finally {
|
||||
if (mountedRef.current) setAddingFeatureKey(null);
|
||||
}
|
||||
}, [instanceId, onRefreshFeatureDataSources]);
|
||||
}, [instanceId, _fetchFeatureDataSources]);
|
||||
|
||||
/* ── Feature: Remove FeatureDataSource ── */
|
||||
const _removeFeatureDataSource = useCallback(async (fdsId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
|
||||
onRefreshFeatureDataSources();
|
||||
_fetchFeatureDataSources();
|
||||
} catch (err) {
|
||||
console.error('Failed to remove feature data source:', err);
|
||||
}
|
||||
}, [instanceId, onRefreshFeatureDataSources]);
|
||||
}, [instanceId, _fetchFeatureDataSources]);
|
||||
|
||||
/* ── Feature: check if table already added ── */
|
||||
const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
|
||||
|
|
@ -409,9 +641,11 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
|||
);
|
||||
}, [featureDataSources]);
|
||||
|
||||
/* ── Render ── */
|
||||
|
||||
return (
|
||||
<div style={{ padding: 8, fontSize: 13 }}>
|
||||
{/* Active DataSources */}
|
||||
<div className={styles.sourcesTab} style={{ padding: 8, fontSize: 13 }}>
|
||||
{/* ── Active Personal Sources ── */}
|
||||
{dataSources.length > 0 && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<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' }}>
|
||||
{connLabel} – {folder}
|
||||
</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
|
||||
onClick={() => _removeDatasource(ds.id)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Tree header */}
|
||||
{/* ── Browse Sources header ── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
||||
Browse Sources
|
||||
|
|
@ -462,7 +717,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
{/* ── Browse Sources tree ── */}
|
||||
{loadingRoot && tree.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||
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' }} />
|
||||
|
||||
{/* Active Feature Data Sources */}
|
||||
{/* ── Active Feature Sources ── */}
|
||||
{featureDataSources.length > 0 && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<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 fdsConnLabel = meta?.instanceLabel || fds.tableName;
|
||||
return (
|
||||
<div key={fds.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
||||
background: '#7b1fa218',
|
||||
borderLeft: '3px solid #7b1fa2',
|
||||
fontSize: 12,
|
||||
}} title={_featureDataSourceHoverTitle(meta, fds)}>
|
||||
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
|
||||
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||
</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{fdsConnLabel} – {fds.tableName}
|
||||
</span>
|
||||
<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 key={fds.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
||||
background: '#7b1fa218',
|
||||
borderLeft: '3px solid #7b1fa2',
|
||||
fontSize: 12,
|
||||
}} title={_featureDataSourceHoverTitle(meta, fds)}>
|
||||
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
|
||||
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||
</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{fdsConnLabel} – {fds.tableName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => _cycleFeatureScope(fds)}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||||
}}
|
||||
title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope} → ${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
|
||||
>
|
||||
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Feature Connections Tree */}
|
||||
{/* ── Feature Data header ── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
||||
Feature Data
|
||||
|
|
@ -540,6 +817,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Feature Data tree ── */}
|
||||
{loadingFeatures && featureTree.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||
Loading feature instances...
|
||||
|
|
@ -567,9 +845,9 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */
|
||||
/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */
|
||||
|
||||
interface TreeNodeViewProps {
|
||||
interface _TreeNodeViewProps {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
onToggle: (node: TreeNode) => void;
|
||||
|
|
@ -578,7 +856,7 @@ interface TreeNodeViewProps {
|
|||
addingPath: string | null;
|
||||
}
|
||||
|
||||
const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
|
||||
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||
node, depth, onToggle, onAdd, isAdded, addingPath,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
|
@ -646,7 +924,6 @@ const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{node.expanded && node.children && node.children.length > 0 && (
|
||||
<div>
|
||||
{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;
|
||||
onToggleGroup: (mandateId: string) => void;
|
||||
onToggleFeature: (node: FeatureConnectionNode) => void;
|
||||
|
|
@ -683,7 +960,7 @@ interface MandateGroupViewProps {
|
|||
addingKey: string | null;
|
||||
}
|
||||
|
||||
const _MandateGroupView: React.FC<MandateGroupViewProps> = ({
|
||||
const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
|
||||
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
|
||||
}) => {
|
||||
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;
|
||||
onToggle: (node: FeatureConnectionNode) => void;
|
||||
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
||||
|
|
@ -739,7 +1016,7 @@ interface FeatureNodeViewProps {
|
|||
addingKey: string | null;
|
||||
}
|
||||
|
||||
const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({
|
||||
const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
||||
node, onToggle, onAddTable, isTableAdded, addingKey,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
|
@ -797,7 +1074,9 @@ const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
interface FeatureTableRowProps {
|
||||
/* ─── FeatureTableRow ────────────────────────────────────────────────── */
|
||||
|
||||
interface _FeatureTableRowProps {
|
||||
featureNode: FeatureConnectionNode;
|
||||
table: FeatureTableNode;
|
||||
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
||||
|
|
@ -805,7 +1084,7 @@ interface FeatureTableRowProps {
|
|||
isAdding: boolean;
|
||||
}
|
||||
|
||||
const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({
|
||||
const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
||||
featureNode, table, onAdd, isAdded, isAdding,
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
|
@ -852,92 +1131,4 @@ const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
/* ─── 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',
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── 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;
|
||||
});
|
||||
}
|
||||
export default SourcesTab;
|
||||
60
src/components/UnifiedDataBar/UnifiedDataBar.module.css
Normal file
60
src/components/UnifiedDataBar/UnifiedDataBar.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
101
src/components/UnifiedDataBar/UnifiedDataBar.tsx
Normal file
101
src/components/UnifiedDataBar/UnifiedDataBar.tsx
Normal 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;
|
||||
3
src/components/UnifiedDataBar/index.ts
Normal file
3
src/components/UnifiedDataBar/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as UnifiedDataBar } from './UnifiedDataBar';
|
||||
export type { UdbContext, UdbTab } from './UnifiedDataBar';
|
||||
export { useUdlContext } from './useUdlContext';
|
||||
23
src/components/UnifiedDataBar/useUdlContext.ts
Normal file
23
src/components/UnifiedDataBar/useUdlContext.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -165,7 +165,7 @@ export function useMandates() {
|
|||
return false; // Don't show readonly fields in edit form
|
||||
}
|
||||
// Also filter out common non-editable fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -305,7 +305,7 @@ export function useMandates() {
|
|||
return false;
|
||||
}
|
||||
// Filter out ID fields and other auto-generated fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ export function useRbacRoles() {
|
|||
return false; // Don't show readonly fields in edit form
|
||||
}
|
||||
// Also filter out common non-editable fields
|
||||
const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -346,7 +346,7 @@ export function useRbacRoles() {
|
|||
return false;
|
||||
}
|
||||
// Filter out ID fields and other auto-generated fields
|
||||
const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export function useRbacRules() {
|
|||
return false; // Don't show readonly fields in edit form
|
||||
}
|
||||
// Also filter out common non-editable fields
|
||||
const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -322,7 +322,7 @@ export function useRbacRules() {
|
|||
return false;
|
||||
}
|
||||
// Filter out ID fields and other auto-generated fields
|
||||
const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ export function useRegister() {
|
|||
interface GoogleAuthResponse {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
isNewUser?: boolean;
|
||||
user: {
|
||||
username: string;
|
||||
email: string;
|
||||
|
|
|
|||
|
|
@ -536,7 +536,7 @@ export function useAutomationTemplates() {
|
|||
return await fetchAutomationTemplateById(request, templateId);
|
||||
}, [request]);
|
||||
|
||||
const createTemplate = useCallback(async (data: Omit<AutomationTemplate, 'id' | '_createdAt' | '_createdBy'>) => {
|
||||
const createTemplate = useCallback(async (data: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>) => {
|
||||
return await createAutomationTemplateApi(request, data);
|
||||
}, [request]);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export type {
|
|||
MandateUserSummary,
|
||||
};
|
||||
|
||||
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
|
||||
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||
|
||||
/**
|
||||
* Hook for user billing operations
|
||||
|
|
@ -217,34 +217,21 @@ export function useBillingAdmin(mandateId?: string) {
|
|||
}
|
||||
}, [request, mandateId]);
|
||||
|
||||
// Update settings — after billing model change, reload dependent data (accounts / users / tx)
|
||||
const saveSettings = useCallback(
|
||||
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
|
||||
const mId = targetMandateId || mandateId;
|
||||
if (!mId) return null;
|
||||
|
||||
const previousModel = settings?.billingModel;
|
||||
|
||||
try {
|
||||
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
|
||||
setSettings(data);
|
||||
const newModel = settingsUpdate.billingModel;
|
||||
const modelChanged =
|
||||
newModel !== undefined && newModel !== null && newModel !== previousModel;
|
||||
if (modelChanged) {
|
||||
await Promise.all([
|
||||
loadAccounts(mId),
|
||||
loadTransactions(mId, 100),
|
||||
loadUsers(mId),
|
||||
]);
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Error saving billing settings:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers]
|
||||
[request, mandateId]
|
||||
);
|
||||
|
||||
// Add credit (manual, admin)
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function useConfirm() {
|
|||
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||
border: '1px solid var(--color-border, #444)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-secondary, #aaa)',
|
||||
color: 'var(--text-primary, #e8e8e8)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -83,11 +83,11 @@ export function useTablePermission(tableName: string) {
|
|||
canDelete: hasAccess(permission.delete),
|
||||
|
||||
// Record-basierte Prüfungen
|
||||
canReadRecord: (record: { _createdBy?: string }) =>
|
||||
canReadRecord: (record: { sysCreatedBy?: string }) =>
|
||||
canAccessRecord(permission.read, record, userId),
|
||||
canUpdateRecord: (record: { _createdBy?: string }) =>
|
||||
canUpdateRecord: (record: { sysCreatedBy?: string }) =>
|
||||
canAccessRecord(permission.update, record, userId),
|
||||
canDeleteRecord: (record: { _createdBy?: string }) =>
|
||||
canDeleteRecord: (record: { sysCreatedBy?: string }) =>
|
||||
canAccessRecord(permission.delete, record, userId),
|
||||
};
|
||||
}
|
||||
|
|
@ -296,7 +296,7 @@ export function useInstancePermissions(): InstancePermissions | undefined {
|
|||
*/
|
||||
export function useCanEditRecord(
|
||||
tableName: string,
|
||||
record: { _createdBy?: string } | undefined,
|
||||
record: { sysCreatedBy?: string } | undefined,
|
||||
userId: string
|
||||
): boolean {
|
||||
const { update } = useTablePermission(tableName);
|
||||
|
|
@ -311,7 +311,7 @@ export function useCanEditRecord(
|
|||
*/
|
||||
export function useCanDeleteRecord(
|
||||
tableName: string,
|
||||
record: { _createdBy?: string } | undefined,
|
||||
record: { sysCreatedBy?: string } | undefined,
|
||||
userId: string
|
||||
): boolean {
|
||||
const { delete: deleteLevel } = useTablePermission(tableName);
|
||||
|
|
@ -329,7 +329,7 @@ interface PermissionGateProps {
|
|||
table?: string;
|
||||
view?: string;
|
||||
action?: 'view' | 'read' | 'create' | 'update' | 'delete';
|
||||
record?: { _createdBy?: string };
|
||||
record?: { sysCreatedBy?: string };
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface FeatureInstance {
|
|||
uiLabel: string;
|
||||
order: number;
|
||||
views: FeatureView[];
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
/** Feature within a mandate */
|
||||
|
|
|
|||
|
|
@ -10,6 +10,48 @@ import api from '../api';
|
|||
|
||||
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
|
||||
|
||||
/** API uses PowerOnModel.sysCreatedAt (seconds); legacy clients used createdAt. */
|
||||
function _coerceToUnixSeconds(value: unknown): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 1e12 ? value / 1000 : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const asNum = Number(trimmed);
|
||||
if (!Number.isNaN(asNum)) {
|
||||
return asNum > 1e12 ? asNum / 1000 : asNum;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) return parsed / 1000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function _normalizeNotificationFromApi(raw: Record<string, unknown>): UserNotification {
|
||||
const partial = raw as unknown as UserNotification;
|
||||
const createdAt =
|
||||
_coerceToUnixSeconds(raw.createdAt) ??
|
||||
_coerceToUnixSeconds(raw.sysCreatedAt) ??
|
||||
(Number.isFinite(partial.createdAt) ? partial.createdAt : 0) ??
|
||||
0;
|
||||
return {
|
||||
...partial,
|
||||
createdAt,
|
||||
readAt: _coerceToUnixSeconds(raw.readAt) ?? partial.readAt,
|
||||
actionedAt: _coerceToUnixSeconds(raw.actionedAt) ?? partial.actionedAt,
|
||||
expiresAt: _coerceToUnixSeconds(raw.expiresAt) ?? partial.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function _normalizeNotificationList(data: unknown): UserNotification[] {
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.map(item =>
|
||||
_normalizeNotificationFromApi(item && typeof item === 'object' ? (item as Record<string, unknown>) : {})
|
||||
);
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface NotificationAction {
|
||||
actionId: string;
|
||||
|
|
@ -30,6 +72,7 @@ export interface UserNotification {
|
|||
actions?: NotificationAction[];
|
||||
actionTaken?: string;
|
||||
actionResult?: string;
|
||||
/** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */
|
||||
createdAt: number;
|
||||
readAt?: number;
|
||||
actionedAt?: number;
|
||||
|
|
@ -74,7 +117,7 @@ export function useNotifications() {
|
|||
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await api.get(url);
|
||||
const data = response.data as UserNotification[];
|
||||
const data = _normalizeNotificationList(response.data);
|
||||
setNotifications(data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
|
|
@ -101,9 +144,9 @@ export function useNotifications() {
|
|||
const listRes = await api.get('/api/notifications', {
|
||||
params: { status: 'unread', limit: 25 },
|
||||
});
|
||||
const list = listRes.data as UserNotification[];
|
||||
const list = _normalizeNotificationList(listRes.data);
|
||||
if (
|
||||
Array.isArray(list) &&
|
||||
list.length > 0 &&
|
||||
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
|
||||
) {
|
||||
window.dispatchEvent(new Event('features-changed'));
|
||||
|
|
|
|||
161
src/hooks/usePrompt.tsx
Normal file
161
src/hooks/usePrompt.tsx
Normal 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 };
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ export function usePrompts() {
|
|||
return false; // Don't show readonly fields in edit form
|
||||
}
|
||||
// Also filter out common non-editable fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -367,7 +367,7 @@ export function usePrompts() {
|
|||
return false;
|
||||
}
|
||||
// Filter out ID fields and other auto-generated fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -530,7 +530,7 @@ export function usePromptOperations() {
|
|||
|
||||
try {
|
||||
// Pass all provided fields (supports partial inline updates like isSystem toggle)
|
||||
const { id, mandateId, _createdBy, _createdAt, _modifiedAt, _permissions, ...requestBody } = updateData;
|
||||
const { id, mandateId, sysCreatedBy, sysCreatedAt, sysModifiedAt, _permissions, ...requestBody } = updateData;
|
||||
|
||||
const updatedPrompt = await updatePromptApi(request, promptId, requestBody as UpdatePromptData);
|
||||
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
|
|||
.filter(attr => {
|
||||
if (attr.readonly === true || attr.editable === false) return false;
|
||||
if (attr.name === 'id') return false;
|
||||
const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
|
||||
const nonEditable = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
|
||||
return !nonEditable.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -210,7 +210,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
|
|||
const generateCreateFieldsFromAttributes = useCallback(() => {
|
||||
if (!attributes || attributes.length === 0) return [];
|
||||
return attributes
|
||||
.filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
|
||||
.filter(attr => !['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
|
||||
.map(attr => {
|
||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
||||
let options: Array<{ value: string | number; label: string }> | undefined;
|
||||
|
|
|
|||
|
|
@ -11,40 +11,64 @@ import {
|
|||
fetchStoreFeatures,
|
||||
activateStoreFeature,
|
||||
deactivateStoreFeature,
|
||||
fetchUserMandates,
|
||||
fetchSubscriptionInfo,
|
||||
type StoreFeature,
|
||||
type UserMandate,
|
||||
type SubscriptionInfo,
|
||||
} from '../api/storeApi';
|
||||
import { useFeatureStore } from '../stores/featureStore';
|
||||
|
||||
interface UseStoreReturn {
|
||||
features: StoreFeature[];
|
||||
mandates: UserMandate[];
|
||||
subscriptionInfo: SubscriptionInfo | null;
|
||||
loading: boolean;
|
||||
actionLoading: string | null;
|
||||
error: string | null;
|
||||
loadStore: () => Promise<void>;
|
||||
activate: (featureCode: string) => Promise<void>;
|
||||
deactivate: (featureCode: string) => Promise<void>;
|
||||
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
||||
activate: (featureCode: string, mandateId?: string) => Promise<void>;
|
||||
deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useStore(): UseStoreReturn {
|
||||
const [features, setFeatures] = useState<StoreFeature[]>([]);
|
||||
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
||||
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const featureStore = useFeatureStore();
|
||||
|
||||
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
||||
try {
|
||||
const info = await fetchSubscriptionInfo(mandateId);
|
||||
setSubscriptionInfo(info);
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStore = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchStoreFeatures();
|
||||
const [data, userMandates] = await Promise.all([
|
||||
fetchStoreFeatures(),
|
||||
fetchUserMandates(),
|
||||
]);
|
||||
setFeatures(data);
|
||||
setMandates(userMandates);
|
||||
const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined;
|
||||
await loadSubscriptionInfo(firstMandateId);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load store';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [loadSubscriptionInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStore();
|
||||
|
|
@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn {
|
|||
await loadStore();
|
||||
}, [featureStore, loadStore]);
|
||||
|
||||
const activate = useCallback(async (featureCode: string) => {
|
||||
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
||||
setActionLoading(featureCode);
|
||||
setError(null);
|
||||
try {
|
||||
await activateStoreFeature(featureCode);
|
||||
await activateStoreFeature(featureCode, mandateId);
|
||||
await _refreshAfterAction();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Activation failed';
|
||||
|
|
@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn {
|
|||
}
|
||||
}, [_refreshAfterAction]);
|
||||
|
||||
const deactivate = useCallback(async (featureCode: string) => {
|
||||
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
||||
setActionLoading(featureCode);
|
||||
setError(null);
|
||||
try {
|
||||
await deactivateStoreFeature(featureCode);
|
||||
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
||||
await _refreshAfterAction();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
||||
|
|
@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn {
|
|||
}
|
||||
}, [_refreshAfterAction]);
|
||||
|
||||
return { features, loading, actionLoading, error, loadStore, activate, deactivate };
|
||||
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
||||
}
|
||||
|
||||
export default useStore;
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
|
|||
if (attr.name === 'id') {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
|
||||
const nonEditableFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -284,7 +284,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
|
|||
|
||||
return attributes
|
||||
.filter(attr => {
|
||||
const systemFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId'];
|
||||
const systemFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId'];
|
||||
return !systemFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export function useTrusteeAccess() {
|
|||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export function useTrusteeContracts() {
|
|||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export function useTrusteeDocuments() {
|
|||
return false;
|
||||
}
|
||||
// documentData is handled separately (binary upload)
|
||||
const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
const nonEditableFields = ['id', 'documentData', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ export function useTrusteeOrganisations() {
|
|||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export function useTrusteePositionDocuments() {
|
|||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function useTrusteePositions() {
|
|||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ export function useTrusteeRoles() {
|
|||
if (attr.readonly === true || attr.editable === false) {
|
||||
return false;
|
||||
}
|
||||
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
||||
const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ export function useOrgUsers() {
|
|||
return false; // Don't show readonly fields in edit form
|
||||
}
|
||||
// Also filter out common non-editable fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
@ -560,7 +560,7 @@ export function useOrgUsers() {
|
|||
return false;
|
||||
}
|
||||
// Filter out ID fields and other auto-generated fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete', 'authenticationAuthority'];
|
||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete', 'authenticationAuthority'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
|
|||
return false; // Don't show readonly fields in edit form
|
||||
}
|
||||
// Also filter out common non-editable fields
|
||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
||||
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||
return !nonEditableFields.includes(attr.name);
|
||||
})
|
||||
.map(attr => {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link, Navigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useNavigation from '../hooks/useNavigation';
|
||||
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
|
||||
import { getPageIcon } from '../config/pageRegistry';
|
||||
import { FaArrowRight, FaBuilding } from 'react-icons/fa';
|
||||
import OnboardingAssistant from '../components/OnboardingAssistant';
|
||||
import styles from './Dashboard.module.css';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (totalInstances === 0) {
|
||||
return <Navigate to="/store" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dashboard}>
|
||||
<header className={styles.header}>
|
||||
<h1>Übersicht</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
|
||||
</p>
|
||||
{totalInstances > 0 && (
|
||||
<p className={styles.subtitle}>
|
||||
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<OnboardingAssistant />
|
||||
|
||||
<main className={styles.content}>
|
||||
{mandates
|
||||
.filter(mandate => mandate.features.some(f => f.instances.length > 0))
|
||||
|
|
|
|||
|
|
@ -242,6 +242,50 @@
|
|||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ctaSection {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctaPrimary {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 25px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.ctaPrimary:hover {
|
||||
background-color: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
.ctaSecondary {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 25px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-secondary);
|
||||
background-color: transparent;
|
||||
color: var(--color-secondary);
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
.ctaSecondary:hover {
|
||||
background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa';
|
|||
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
|
||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
|
||||
import styles from './Login.module.css';
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ function Login() {
|
|||
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
||||
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
||||
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
||||
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
|
||||
|
||||
// Check for pending invitation
|
||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||
|
|
@ -84,6 +86,10 @@ function Login() {
|
|||
console.log("Attempting Google login...");
|
||||
const response = await loginWithGoogle();
|
||||
console.log("Google login successful:", response);
|
||||
if (response?.isNewUser) {
|
||||
setShowOnboardingWizard(true);
|
||||
return;
|
||||
}
|
||||
handleSuccessfulLogin();
|
||||
} catch (error) {
|
||||
console.error("Google login failed:", error);
|
||||
|
|
@ -104,6 +110,21 @@ function Login() {
|
|||
}
|
||||
};
|
||||
|
||||
if (showOnboardingWizard) {
|
||||
return (
|
||||
<OnboardingWizard
|
||||
onComplete={() => {
|
||||
setShowOnboardingWizard(false);
|
||||
handleSuccessfulLogin();
|
||||
}}
|
||||
onDismiss={() => {
|
||||
setShowOnboardingWizard(false);
|
||||
handleSuccessfulLogin();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.mainContent}>
|
||||
|
|
@ -213,12 +234,15 @@ function Login() {
|
|||
</button>
|
||||
|
||||
<div className={styles.registerLink}>
|
||||
<span>Du hast noch keinen Konto?</span>
|
||||
<span>Du hast noch kein Konto?</span>
|
||||
</div>
|
||||
<div className={styles.ctaSection}>
|
||||
<button
|
||||
className={styles.textButton}
|
||||
onClick={() => navigate("/register", { state: location.state })}
|
||||
type="button"
|
||||
className={styles.ctaPrimary}
|
||||
onClick={() => navigate('/register', { state: location.state })}
|
||||
>
|
||||
Registrieren
|
||||
Kostenlos registrieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ function Register() {
|
|||
const { register, error: registerError, isLoading } = useRegister();
|
||||
const { error: msalError } = useMsalRegister();
|
||||
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
|
||||
// Pre-fill from invitation if provided via location.state
|
||||
const invitationUsername = (location.state as any)?.invitationUsername || '';
|
||||
const invitationEmail = (location.state as any)?.invitationEmail || '';
|
||||
const [formData, setFormData] = useState<RegisterFormData>({
|
||||
|
|
@ -34,15 +33,11 @@ function Register() {
|
|||
const [fullNameFocused, setFullNameFocused] = useState(false);
|
||||
const [usernameHighlight, setUsernameHighlight] = useState(false);
|
||||
|
||||
// Check for pending invitation
|
||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||
const hasPendingInvitation = !!pendingInvitationToken;
|
||||
|
||||
// Set page title and generate CSRF token
|
||||
useEffect(() => {
|
||||
document.title = "PowerOn AI Platform - Registrieren";
|
||||
|
||||
// Generate CSRF token for new security implementation
|
||||
generateAndStoreCSRFToken();
|
||||
}, []);
|
||||
|
||||
|
|
@ -53,13 +48,12 @@ function Register() {
|
|||
[name]: value
|
||||
}));
|
||||
setValidationError(null);
|
||||
// Reset username highlight when user starts typing in username field
|
||||
if (name === 'username') {
|
||||
setUsernameHighlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const _validateForm = (): boolean => {
|
||||
if (!formData.username || !formData.email || !formData.fullName) {
|
||||
setValidationError('Bitte füllen Sie alle Pflichtfelder aus.');
|
||||
return false;
|
||||
|
|
@ -76,16 +70,14 @@ function Register() {
|
|||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
if (!_validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// First check username availability
|
||||
const availabilityResult = await checkAvailability(formData.username, 'local');
|
||||
|
||||
if (!availabilityResult.available) {
|
||||
// Check if the error message is about username being taken
|
||||
const errorMessage = availabilityResult.message || 'Username is not available';
|
||||
if (errorMessage === 'Username is already taken') {
|
||||
setValidationError('Benutzername ist bereits vergeben');
|
||||
|
|
@ -96,25 +88,20 @@ function Register() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Username is available, proceed with registration (no password - magic link flow)
|
||||
await register(formData);
|
||||
await register({ ...formData, registrationType: 'personal' });
|
||||
|
||||
// Build success message
|
||||
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
|
||||
if (hasPendingInvitation) {
|
||||
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
|
||||
}
|
||||
|
||||
// Show success message instead of immediate redirect
|
||||
setSuccessMessage(message);
|
||||
|
||||
// Redirect to login page after delay
|
||||
setTimeout(() => {
|
||||
navigate('/login', {
|
||||
state: {
|
||||
registered: true,
|
||||
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
|
||||
// Pass along invitation state
|
||||
...(location.state || {})
|
||||
}
|
||||
});
|
||||
|
|
@ -124,8 +111,7 @@ function Register() {
|
|||
}
|
||||
};
|
||||
|
||||
// Helper function to safely get error message
|
||||
const getErrorMessage = () => {
|
||||
const _getErrorMessage = () => {
|
||||
if (validationError) return validationError;
|
||||
if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed';
|
||||
if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed';
|
||||
|
|
@ -146,7 +132,6 @@ function Register() {
|
|||
<div className={styles.loginSection}>
|
||||
<div className={styles.loginBox}>
|
||||
<div className={styles.loginForm}>
|
||||
{/* Pending invitation notice */}
|
||||
{hasPendingInvitation && !successMessage && (
|
||||
<div className={styles.invitationNotice}>
|
||||
<FaEnvelopeOpenText className={styles.invitationIcon} />
|
||||
|
|
@ -154,8 +139,8 @@ function Register() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{getErrorMessage() && (
|
||||
<div className={styles.error}>{getErrorMessage()}</div>
|
||||
{_getErrorMessage() && (
|
||||
<div className={styles.error}>{_getErrorMessage()}</div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
|
|
@ -221,7 +206,7 @@ function Register() {
|
|||
onClick={handleSubmit}
|
||||
disabled={isLoading || isChecking}
|
||||
>
|
||||
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : "Registrieren"}
|
||||
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
/**
|
||||
* Settings Page
|
||||
*
|
||||
* Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext).
|
||||
* Settings Page — User-level settings with tabs.
|
||||
* Route: /settings
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
||||
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
||||
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||
import { useApiRequest } from '../hooks/useApi';
|
||||
import styles from './Settings.module.css';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
|
||||
|
||||
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||
{ key: 'profile', label: 'Profil' },
|
||||
{ key: 'appearance', label: 'Darstellung' },
|
||||
{ key: 'voice', label: 'Stimme & Sprache' },
|
||||
{ key: 'neutralization', label: 'Neutralisierung (lokal)' },
|
||||
{ key: 'privacy', label: 'Datenschutz' },
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// PROFILE EDIT MODAL
|
||||
// =============================================================================
|
||||
|
|
@ -27,39 +41,13 @@ interface ProfileEditModalProps {
|
|||
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Define editable profile fields
|
||||
|
||||
const profileAttributes: AttributeDefinition[] = [
|
||||
{
|
||||
name: 'fullName',
|
||||
type: 'string',
|
||||
label: 'Vollständiger Name',
|
||||
description: 'Ihr vollständiger Name',
|
||||
required: false,
|
||||
placeholder: 'Max Mustermann'
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: 'E-Mail-Adresse',
|
||||
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
|
||||
required: true,
|
||||
placeholder: 'name@example.com'
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
type: 'select',
|
||||
label: 'Sprache',
|
||||
description: 'Anzeigesprache der Anwendung',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' }
|
||||
]
|
||||
}
|
||||
{ name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' },
|
||||
{ name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' },
|
||||
{ name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] },
|
||||
];
|
||||
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
|
@ -72,9 +60,9 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
|||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onClose}>
|
||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
|
|
@ -84,21 +72,358 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
|||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
<FormGeneratorForm
|
||||
attributes={profileAttributes}
|
||||
data={userData}
|
||||
mode="edit"
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onClose}
|
||||
submitButtonText={isSaving ? 'Speichern...' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? 'Speichern...' : 'Speichern'} cancelButtonText="Abbrechen" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// VOICE SETTINGS TAB
|
||||
// =============================================================================
|
||||
|
||||
interface VoiceMapEntry { language: string; voiceName: string; }
|
||||
|
||||
const VoiceSettingsTab: React.FC = () => {
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [sttLanguage, setSttLanguage] = useState('de-DE');
|
||||
const [languages, setLanguages] = useState<any[]>([]);
|
||||
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||
|
||||
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||
const [addVoices, setAddVoices] = useState<any[]>([]);
|
||||
const [addVoiceName, setAddVoiceName] = useState('');
|
||||
const [loadingVoices, setLoadingVoices] = useState(false);
|
||||
|
||||
const _loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [prefsData, languagesData] = await Promise.all([
|
||||
request({ url: '/api/voice/preferences', method: 'get' }),
|
||||
request({ url: '/api/voice/languages', method: 'get' }),
|
||||
]);
|
||||
|
||||
const langList = (languagesData as any)?.languages || [];
|
||||
setLanguages(langList);
|
||||
|
||||
const prefs = prefsData as any;
|
||||
setSttLanguage(prefs?.sttLanguage || 'de-DE');
|
||||
|
||||
const map: Record<string, any> = prefs?.ttsVoiceMap || {};
|
||||
const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({
|
||||
language: lang,
|
||||
voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '',
|
||||
}));
|
||||
setVoiceMap(entries);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Laden der Voice-Einstellungen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => { _loadSettings(); }, [_loadSettings]);
|
||||
|
||||
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
|
||||
setLoadingVoices(true);
|
||||
try {
|
||||
const result = await request({ url: '/api/voice/voices', method: 'get', params: { language: lang } });
|
||||
setAddVoices((result as any)?.voices || []);
|
||||
setAddVoiceName('');
|
||||
} catch { setAddVoices([]); }
|
||||
finally { setLoadingVoices(false); }
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]);
|
||||
|
||||
const _handleAddEntry = useCallback(() => {
|
||||
if (!addLanguage) return;
|
||||
const exists = voiceMap.some(e => e.language === addLanguage);
|
||||
if (exists) {
|
||||
setVoiceMap(prev => prev.map(e => e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e));
|
||||
} else {
|
||||
setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]);
|
||||
}
|
||||
setAddVoiceName('');
|
||||
}, [addLanguage, addVoiceName, voiceMap]);
|
||||
|
||||
const _handleRemoveEntry = useCallback((lang: string) => {
|
||||
setVoiceMap(prev => prev.filter(e => e.language !== lang));
|
||||
}, []);
|
||||
|
||||
const _handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const mapObj: Record<string, any> = {};
|
||||
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
|
||||
await request({
|
||||
url: '/api/voice/preferences',
|
||||
method: 'put',
|
||||
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
|
||||
});
|
||||
setSuccess('Einstellungen gespeichert');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
await _loadSettings();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Speichern');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, voiceMap, sttLanguage, _loadSettings]);
|
||||
|
||||
const _handleTestVoice = useCallback(async (lang: string, voice: string) => {
|
||||
setTesting(lang);
|
||||
try {
|
||||
const result: any = await request({
|
||||
url: '/api/voice/test',
|
||||
method: 'post',
|
||||
data: { language: lang, voiceId: voice || undefined },
|
||||
});
|
||||
if (result?.success && result?.audio) {
|
||||
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
||||
audio.play();
|
||||
}
|
||||
} catch { setError('Stimmtest fehlgeschlagen'); }
|
||||
finally { setTesting(null); }
|
||||
}, [request]);
|
||||
|
||||
const _getLanguageName = useCallback((code: string) => {
|
||||
const found = languages.find((l: any) => (l.code || l) === code);
|
||||
return found?.name || found?.code || code;
|
||||
}, [languages]);
|
||||
|
||||
const _defaultLangs = [
|
||||
{ code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' },
|
||||
{ code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' },
|
||||
{ code: 'es-ES', name: 'Espanol' },
|
||||
];
|
||||
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
|
||||
|
||||
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Einstellungen werden geladen...</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>STT-Sprache (Spracheingabe)</h2>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Sprache fuer Spracherkennung</label>
|
||||
<p className={styles.settingDescription}>Wird fuer die Sprache-zu-Text-Erkennung verwendet.</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
|
||||
{_displayLanguages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>TTS-Stimmen (Sprachausgabe)</h2>
|
||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
|
||||
</p>
|
||||
|
||||
{voiceMap.length === 0 ? (
|
||||
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
|
||||
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead><tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}><th style={{ textAlign: 'left', padding: '0.5rem' }}>Sprache</th><th style={{ textAlign: 'left', padding: '0.5rem' }}>Stimme</th><th /><th /></tr></thead>
|
||||
<tbody>
|
||||
{voiceMap.map(entry => (
|
||||
<tr key={entry.language} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{_getLanguageName(entry.language)}</td>
|
||||
<td style={{ padding: '0.5rem' }}>{entry.voiceName || 'Standard'}</td>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }} onClick={() => _handleTestVoice(entry.language, entry.voiceName)} disabled={testing === entry.language}>
|
||||
{testing === entry.language ? '...' : 'Test'}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }} onClick={() => _handleRemoveEntry(entry.language)}>Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Sprache</label>
|
||||
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
|
||||
{_displayLanguages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Stimme</label>
|
||||
<select className={styles.select} value={addVoiceName} onChange={e => setAddVoiceName(e.target.value)} disabled={loadingVoices}>
|
||||
<option value="">Standard</option>
|
||||
{addVoices.map((v: any) => (
|
||||
<option key={v.name || v} value={v.name || v}>{v.displayName || v.name || v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>Zuweisen</button>
|
||||
<button className={styles.button} onClick={() => _handleTestVoice(addLanguage, addVoiceName)} disabled={testing !== null} style={{ padding: '0.5rem 1rem' }}>
|
||||
{testing === addLanguage ? '...' : 'Testen'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
|
||||
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// NEUTRALIZATION MAPPINGS TAB
|
||||
// =============================================================================
|
||||
|
||||
interface NeutralizationMapping {
|
||||
id: string;
|
||||
originalText: string;
|
||||
patternType: string;
|
||||
fileId?: string;
|
||||
featureInstanceId?: string;
|
||||
}
|
||||
|
||||
const NeutralizationMappingsTab: React.FC = () => {
|
||||
const { request } = useApiRequest();
|
||||
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const _load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result: any = await request({ url: '/api/local/neutralization-mappings', method: 'get' });
|
||||
const items = (result?.mappings || []).map((m: any) => ({
|
||||
id: m.id,
|
||||
originalText: m.originalText || '',
|
||||
patternType: m.patternType || '',
|
||||
fileId: m.fileId,
|
||||
featureInstanceId: m.featureInstanceId,
|
||||
}));
|
||||
setMappings(items);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Laden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => { _load(); }, [_load]);
|
||||
|
||||
const _handleDelete = useCallback(async (id: string) => {
|
||||
try {
|
||||
await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' });
|
||||
setMappings(prev => prev.filter(m => m.id !== id));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Loeschen');
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const _maskText = (text: string) => {
|
||||
if (text.length <= 4) return '****';
|
||||
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Mappings werden geladen...</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Platzhalter-Mappings (lokal)</h2>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--surface-color, #eff6ff)',
|
||||
border: '1px solid var(--border-color, #bfdbfe)',
|
||||
borderRadius: 8,
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text-primary, #1e3a5f)',
|
||||
}}
|
||||
>
|
||||
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
|
||||
<strong>Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“</strong> (nicht auf dieser
|
||||
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
|
||||
</div>
|
||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle
|
||||
geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber
|
||||
den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings — hier einsehbar und loeschbar.
|
||||
</p>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
|
||||
Keine Neutralisierungs-Mappings vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Platzhalter-ID</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Originaltext</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Typ</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mappings.map(m => (
|
||||
<tr key={m.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||
<td style={{ padding: '0.5rem', fontFamily: 'monospace', fontSize: '0.75rem' }}>{m.id.slice(0, 12)}...</td>
|
||||
<td style={{ padding: '0.5rem' }}>{_maskText(m.originalText)}</td>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: '#f3f4f6' }}>
|
||||
{m.patternType}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem' }}>
|
||||
<button
|
||||
className={styles.button}
|
||||
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}
|
||||
onClick={() => _handleDelete(m.id)}
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SETTINGS PAGE
|
||||
// =============================================================================
|
||||
|
|
@ -107,266 +432,142 @@ export const SettingsPage: React.FC = () => {
|
|||
const { currentLanguage, setLanguage } = useLanguage();
|
||||
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
||||
const { updateUser } = useUser();
|
||||
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(
|
||||
() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
|
||||
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
||||
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
||||
const [languageError, setLanguageError] = useState<string | null>(null);
|
||||
|
||||
// Handle theme change
|
||||
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
if (newTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark-theme');
|
||||
document.documentElement.classList.remove('light-theme');
|
||||
} else {
|
||||
document.documentElement.classList.add('light-theme');
|
||||
document.documentElement.classList.remove('dark-theme');
|
||||
}
|
||||
if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); }
|
||||
else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); }
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
};
|
||||
|
||||
// Handle language change - save to backend and update cache
|
||||
|
||||
const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => {
|
||||
if (!currentUser?.id || !currentUser?.username) return;
|
||||
|
||||
setIsSavingLanguage(true);
|
||||
setLanguageError(null);
|
||||
|
||||
try {
|
||||
// 1. Build full user object for update (backend requires full User model)
|
||||
const userUpdateData = {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
email: currentUser.email,
|
||||
fullName: currentUser.fullName,
|
||||
language: newLanguage,
|
||||
enabled: currentUser.enabled ?? true,
|
||||
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
||||
};
|
||||
|
||||
// 2. Save to backend
|
||||
await updateUser(currentUser.id, userUpdateData);
|
||||
|
||||
// 3. Update sessionStorage cache
|
||||
await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: currentUser.email, fullName: currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
|
||||
const cachedUser = getUserDataCache();
|
||||
if (cachedUser) {
|
||||
setUserDataCache({ ...cachedUser, language: newLanguage });
|
||||
}
|
||||
|
||||
// 4. Update UI language context
|
||||
if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage });
|
||||
setLanguage(newLanguage);
|
||||
|
||||
// 5. Dispatch event to notify other components
|
||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||
|
||||
console.log('Language updated successfully to:', newLanguage);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update language:', err);
|
||||
setLanguageError('Sprache konnte nicht gespeichert werden');
|
||||
} finally {
|
||||
setIsSavingLanguage(false);
|
||||
}
|
||||
} catch { setLanguageError('Sprache konnte nicht gespeichert werden'); }
|
||||
finally { setIsSavingLanguage(false); }
|
||||
}, [currentUser, updateUser, setLanguage]);
|
||||
|
||||
// Handle profile save
|
||||
|
||||
const handleProfileSave = useCallback(async (formData: any) => {
|
||||
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
|
||||
|
||||
// Get the new language (from form or current user)
|
||||
const newLanguage = formData.language || currentUser.language || 'de';
|
||||
|
||||
// Build full user object for update (backend requires full User model)
|
||||
const userUpdateData = {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
email: formData.email || currentUser.email,
|
||||
fullName: formData.fullName || currentUser.fullName,
|
||||
language: newLanguage,
|
||||
enabled: currentUser.enabled ?? true,
|
||||
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
||||
};
|
||||
|
||||
// Update user via API
|
||||
const updatedUser = await updateUser(currentUser.id, userUpdateData);
|
||||
|
||||
// Update sessionStorage cache
|
||||
const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
|
||||
const cachedUser = getUserDataCache();
|
||||
if (cachedUser) {
|
||||
setUserDataCache({
|
||||
...cachedUser,
|
||||
fullName: updatedUser.fullName || cachedUser.fullName,
|
||||
email: updatedUser.email || cachedUser.email,
|
||||
language: newLanguage
|
||||
});
|
||||
}
|
||||
|
||||
// Update UI language if changed
|
||||
if (newLanguage !== currentLanguage) {
|
||||
setLanguage(newLanguage as 'de' | 'en' | 'fr');
|
||||
}
|
||||
|
||||
// Refetch user data
|
||||
if (refetchUser) {
|
||||
await refetchUser();
|
||||
}
|
||||
|
||||
// Dispatch event to notify other components (e.g., sidebar)
|
||||
if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage });
|
||||
if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr');
|
||||
if (refetchUser) await refetchUser();
|
||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||
|
||||
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.settings}>
|
||||
<header className={styles.header}>
|
||||
<h1>Einstellungen</h1>
|
||||
<p className={styles.subtitle}>Persönliche Einstellungen und Präferenzen</p>
|
||||
<p className={styles.subtitle}>Persoenliche Einstellungen und Praeferenzen</p>
|
||||
</header>
|
||||
|
||||
|
||||
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
|
||||
{_TABS.map(tab => (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
|
||||
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent',
|
||||
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400,
|
||||
color: activeTab === tab.key ? 'var(--primary-color, #2563eb)' : 'var(--text-secondary, #888)',
|
||||
}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className={styles.content}>
|
||||
{/* Darstellung */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Theme</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Wähle zwischen hellem und dunklem Design.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<div className={styles.themeToggle}>
|
||||
<button
|
||||
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`}
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
Hell
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`}
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
Dunkel
|
||||
</button>
|
||||
{activeTab === 'profile' && (
|
||||
<>
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Konto</h2>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||
<p className={styles.settingDescription}>Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>Profil oeffnen</button>
|
||||
</div>
|
||||
</div>
|
||||
{currentUser && (
|
||||
<div className={styles.userInfoCard}>
|
||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Benutzername</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
|
||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Name</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
|
||||
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>E-Mail</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Ueber</h2>
|
||||
<div className={styles.infoCard}>
|
||||
<div className={styles.infoRow}><span className={styles.infoLabel}>Version</span><span className={styles.infoValue}>2.0.0</span></div>
|
||||
<div className={styles.infoRow}><span className={styles.infoLabel}>Build</span><span className={styles.infoValue}>2026.03.23</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>Waehlen Sie zwischen hellem und dunklem Design.</p></div>
|
||||
<div className={styles.settingControl}>
|
||||
<div className={styles.themeToggle}>
|
||||
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>Hell</button>
|
||||
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>Dunkel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Sprache</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Wähle die Anzeigesprache der Anwendung.
|
||||
{languageError && <span className={styles.errorText}> {languageError}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={currentLanguage}
|
||||
onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')}
|
||||
disabled={isSavingLanguage}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Konto */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Konto</h2>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Ändere deinen Namen und E-Mail-Adresse.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={async () => {
|
||||
await refetchUser();
|
||||
setIsProfileModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Profil öffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current user info display */}
|
||||
{currentUser && (
|
||||
<div className={styles.userInfoCard}>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>Benutzername</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.username}</span>
|
||||
</div>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>Name</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>E-Mail</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.email || '-'}</span>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>Anzeigesprache</label><p className={styles.settingDescription}>Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
|
||||
<div className={styles.settingControl}>
|
||||
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')} disabled={isSavingLanguage}>
|
||||
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
|
||||
</select>
|
||||
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Datenschutz */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>GDPR / Privacy</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Data export, portability and account deletion.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'voice' && <VoiceSettingsTab />}
|
||||
|
||||
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||
Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich
|
||||
nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export,
|
||||
Löschung) finden Sie unter GDPR.
|
||||
</p>
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}><label className={styles.settingLabel}>GDPR / Privacy</label><p className={styles.settingDescription}>Datenexport, Portabilität und Kontolöschung.</p></div>
|
||||
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR öffnen</Link></div>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">
|
||||
Open GDPR page
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Info */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Über</h2>
|
||||
|
||||
<div className={styles.infoCard}>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Version</span>
|
||||
<span className={styles.infoValue}>2.0.0</span>
|
||||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Build</span>
|
||||
<span className={styles.infoValue}>2026.01.20</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Profile Edit Modal */}
|
||||
<ProfileEditModal
|
||||
isOpen={isProfileModalOpen}
|
||||
onClose={() => setIsProfileModalOpen(false)}
|
||||
userData={currentUser}
|
||||
onSave={handleProfileSave}
|
||||
/>
|
||||
|
||||
<ProfileEditModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,52 @@
|
|||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
/* Subscription Banner */
|
||||
.subscriptionBanner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--info-bg, #eff6ff);
|
||||
border: 1px solid var(--info-border, #bfdbfe);
|
||||
color: var(--info-color, #1e40af);
|
||||
}
|
||||
|
||||
.bannerSeparator::before {
|
||||
content: '|';
|
||||
margin-right: 0.25rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Mandate Select */
|
||||
.mandateSelect {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--surface-color, #ffffff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.mandateSelect:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mandateHint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
|
|
@ -120,8 +166,54 @@
|
|||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Instance List */
|
||||
.instanceList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.instanceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.instanceInfo {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deactivateButtonSmall {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.deactivateButtonSmall:hover:not(:disabled) {
|
||||
border-color: var(--error-color, #dc2626);
|
||||
color: var(--error-color, #dc2626);
|
||||
background: var(--error-bg, #fef2f2);
|
||||
}
|
||||
|
||||
.deactivateButtonSmall:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.cardActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
|
@ -243,17 +335,35 @@
|
|||
border-top-color: var(--border-dark, #333);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .deactivateButton {
|
||||
:global(.dark-theme) .deactivateButton,
|
||||
:global(.dark-theme) .deactivateButtonSmall {
|
||||
border-color: var(--border-dark, #444);
|
||||
color: var(--text-secondary-dark, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .deactivateButton:hover:not(:disabled) {
|
||||
:global(.dark-theme) .deactivateButton:hover:not(:disabled),
|
||||
:global(.dark-theme) .deactivateButtonSmall:hover:not(:disabled) {
|
||||
border-color: var(--error-color-dark, #f87171);
|
||||
color: var(--error-color-dark, #f87171);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .subscriptionBanner {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-color: rgba(37, 99, 235, 0.25);
|
||||
color: var(--primary-light, #93bbfc);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .mandateSelect {
|
||||
background: var(--surface-dark, #1a1a1a);
|
||||
border-color: var(--border-dark, #444);
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .mandateHint {
|
||||
color: var(--text-secondary-dark, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .error {
|
||||
background: var(--error-bg-dark, #450a0a);
|
||||
border-color: var(--error-border-dark, #991b1b);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
/**
|
||||
* Store Page
|
||||
*
|
||||
* Feature Store where users can self-activate features in the root mandate.
|
||||
* Uses the Shared Instance Pattern -- each feature has one shared instance,
|
||||
* and users get their own FeatureAccess + user-role upon activation.
|
||||
* Feature Store -- Users activate feature instances in their own mandates.
|
||||
* Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance
|
||||
* in the selected mandate. Explicit mandate selection required.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useStore } from '../hooks/useStore';
|
||||
import type { StoreFeature } from '../api/storeApi';
|
||||
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
||||
import styles from './Store.module.css';
|
||||
|
||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||
|
|
@ -62,23 +61,27 @@ function _getDescription(featureCode: string, lang: string): string {
|
|||
interface FeatureCardProps {
|
||||
feature: StoreFeature;
|
||||
language: string;
|
||||
mandates: UserMandate[];
|
||||
actionLoading: string | null;
|
||||
onActivate: (code: string) => void;
|
||||
onDeactivate: (code: string) => void;
|
||||
onActivate: (code: string, mandateId?: string) => void;
|
||||
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
||||
}
|
||||
|
||||
const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
feature,
|
||||
language,
|
||||
mandates,
|
||||
actionLoading,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
}) => {
|
||||
const isProcessing = actionLoading === feature.featureCode;
|
||||
const icon = FEATURE_ICONS[feature.featureCode];
|
||||
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
||||
const hasActive = activeInstances.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}>
|
||||
<div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
|
||||
<div className={styles.cardHeader}>
|
||||
{icon && <span className={styles.cardIcon}>{icon}</span>}
|
||||
<h3 className={styles.cardTitle}>
|
||||
|
|
@ -92,37 +95,56 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{feature.isActive
|
||||
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active')
|
||||
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')}
|
||||
</span>
|
||||
</div>
|
||||
{activeInstances.length > 0 && (
|
||||
<div className={styles.instanceList}>
|
||||
{activeInstances.map((inst) => (
|
||||
<div key={inst.instanceId} className={styles.instanceRow}>
|
||||
<div className={styles.instanceInfo}>
|
||||
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{inst.mandateName || inst.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className={styles.deactivateButtonSmall}
|
||||
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? '...'
|
||||
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeInstances.length === 0 && (
|
||||
<div>
|
||||
<span className={`${styles.statusBadge} ${styles.statusInactive}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
{feature.isActive ? (
|
||||
{feature.canActivate && mandates.map((m) => (
|
||||
<button
|
||||
className={styles.deactivateButton}
|
||||
onClick={() => onDeactivate(feature.featureCode)}
|
||||
key={m.id}
|
||||
className={styles.activateButton}
|
||||
onClick={() => onActivate(feature.featureCode, m.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing
|
||||
? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...')
|
||||
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={styles.activateButton}
|
||||
onClick={() => onActivate(feature.featureCode)}
|
||||
disabled={isProcessing || !feature.canActivate}
|
||||
>
|
||||
{isProcessing
|
||||
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
||||
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
|
||||
: (language === 'de'
|
||||
? `Aktivieren fuer ${m.label || m.name}`
|
||||
: language === 'fr'
|
||||
? `Activer pour ${m.label || m.name}`
|
||||
: `Activate for ${m.label || m.name}`)}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -130,7 +152,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
|||
|
||||
const StorePage: React.FC = () => {
|
||||
const { currentLanguage } = useLanguage();
|
||||
const { features, loading, actionLoading, error, activate, deactivate } = useStore();
|
||||
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
|
||||
|
||||
return (
|
||||
<div className={styles.store}>
|
||||
|
|
@ -145,6 +167,33 @@ const StorePage: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{subscriptionInfo && subscriptionInfo.plan && (
|
||||
<div className={styles.subscriptionBanner}>
|
||||
<span>Plan: <strong>{subscriptionInfo.plan}</strong></span>
|
||||
{subscriptionInfo.maxFeatureInstances != null && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}
|
||||
</span>
|
||||
)}
|
||||
{subscriptionInfo.maxDataVolumeMB != null && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '}
|
||||
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
|
||||
</span>
|
||||
)}
|
||||
{subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF
|
||||
</span>
|
||||
)}
|
||||
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
||||
<span className={styles.bannerSeparator}>
|
||||
{currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -164,6 +213,7 @@ const StorePage: React.FC = () => {
|
|||
key={feature.featureCode}
|
||||
feature={feature}
|
||||
language={currentLanguage}
|
||||
mandates={mandates}
|
||||
actionLoading={actionLoading}
|
||||
onActivate={activate}
|
||||
onDeactivate={deactivate}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
splitMandateAndBillingFromForm,
|
||||
} from '../../utils/mandateBillingFormMerge';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
||||
|
|
@ -23,6 +24,7 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
const navigate = useNavigate();
|
||||
const { request } = useApiRequest();
|
||||
const { showWarning, showSuccess } = useToast();
|
||||
const { prompt, PromptDialog } = usePrompt();
|
||||
const {
|
||||
mandates,
|
||||
columns,
|
||||
|
|
@ -111,11 +113,18 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
setEditingBillingWarning(null);
|
||||
};
|
||||
|
||||
// Handle delete (confirmation handled by DeleteActionButton)
|
||||
// System mandates (isSystem=true) are protected from deletion
|
||||
const handleDeleteMandate = async (mandate: Mandate) => {
|
||||
if (mandate.isSystem) {
|
||||
return; // Safety guard - should not be reachable due to disabled button
|
||||
return;
|
||||
}
|
||||
const entered = await prompt(
|
||||
`Um den Mandanten "${mandate.name}" 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);
|
||||
};
|
||||
|
|
@ -267,6 +276,8 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<PromptDialog />
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingFormData && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ export const AdminMandateWizardPage: React.FC = () => {
|
|||
if (billingSaved) {
|
||||
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('features-changed'));
|
||||
await loadMandates();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export const ConnectionsPage: React.FC = () => {
|
|||
|
||||
// Form attributes for edit modal
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked'];
|
||||
const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked'];
|
||||
return (attributes || [])
|
||||
.filter(attr => !excludedFields.includes(attr.name));
|
||||
}, [attributes]);
|
||||
|
|
@ -244,7 +244,9 @@ export const ConnectionsPage: React.FC = () => {
|
|||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Verbindungen</h1>
|
||||
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten (Google, Microsoft, ClickUp)</p>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { FileNode } from '../../components/FolderTree/FolderTree';
|
|||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import styles from '../admin/Admin.module.css';
|
||||
|
||||
interface UserFile {
|
||||
|
|
@ -31,6 +32,7 @@ interface UserFile {
|
|||
export const FilesPage: React.FC = () => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { showSuccess, showError } = useToast();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
|
|
@ -142,7 +144,7 @@ export const FilesPage: React.FC = () => {
|
|||
}));
|
||||
|
||||
cols.push({
|
||||
key: '_createdBy',
|
||||
key: 'sysCreatedBy',
|
||||
label: 'Created By',
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
|
|
@ -223,11 +225,11 @@ export const FilesPage: React.FC = () => {
|
|||
};
|
||||
|
||||
const _handleNewFolder = useCallback(async () => {
|
||||
const name = prompt('Neuer Ordnername:');
|
||||
const name = await promptInput('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||
if (name?.trim()) {
|
||||
await handleCreateFolder(name.trim(), selectedFolderId);
|
||||
}
|
||||
}, [handleCreateFolder, selectedFolderId]);
|
||||
}, [handleCreateFolder, selectedFolderId, promptInput]);
|
||||
|
||||
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
||||
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
||||
|
|
@ -289,7 +291,7 @@ export const FilesPage: React.FC = () => {
|
|||
}, [selectedFolderId, _tableRefetch]);
|
||||
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
|
||||
const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source'];
|
||||
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PromptDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const PromptsPage: React.FC = () => {
|
|||
// Generate columns from attributes - exclude ID fields from display
|
||||
const columns = useMemo(() => {
|
||||
// Fields to hide in table view
|
||||
const hiddenColumns = ['id', 'mandateId', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
|
||||
const hiddenColumns = ['id', 'mandateId', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions'];
|
||||
|
||||
const cols = (attributes || [])
|
||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||
|
|
@ -71,9 +71,9 @@ export const PromptsPage: React.FC = () => {
|
|||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
}));
|
||||
|
||||
// Add _createdBy column with FK resolution to show username
|
||||
// Add sysCreatedBy column with FK resolution to show username
|
||||
cols.push({
|
||||
key: '_createdBy',
|
||||
key: 'sysCreatedBy',
|
||||
label: 'Created By',
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
|
|
@ -148,7 +148,7 @@ export const PromptsPage: React.FC = () => {
|
|||
|
||||
// Form attributes for create/edit modal
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'isSystem', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
|
||||
const excludedFields = ['id', 'mandateId', 'isSystem', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions'];
|
||||
return (attributes || [])
|
||||
.filter(attr => !excludedFields.includes(attr.name));
|
||||
}, [attributes]);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.billingModel {
|
||||
.mandateSubtitle {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
|
|
|
|||
|
|
@ -85,10 +85,11 @@ interface SettingsEditorProps {
|
|||
|
||||
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'],
|
||||
defaultUserCredit: Number(settings?.defaultUserCredit ?? 0),
|
||||
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
||||
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
||||
autoRechargeEnabled: settings?.autoRechargeEnabled ?? false,
|
||||
rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10),
|
||||
rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3),
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
|
@ -96,10 +97,11 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData({
|
||||
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
|
||||
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
|
||||
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||
autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
|
||||
rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
|
||||
rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
|
@ -130,32 +132,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Abrechnungsmodell</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={formData.billingModel}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as BillingSettings['billingModel'] }))}
|
||||
>
|
||||
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
|
||||
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label>Standard-Guthaben (CHF)</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={formData.defaultUserCredit}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))}
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Warnschwelle (%)</label>
|
||||
|
|
@ -184,6 +160,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup} style={{ gridColumn: '1 / -1' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.autoRechargeEnabled}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, autoRechargeEnabled: e.target.checked }))}
|
||||
/>
|
||||
Auto-Nachladung (AI-Guthaben bei niedrigem Stand)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{formData.autoRechargeEnabled && (
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Betrag pro Nachladung (CHF)</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={formData.rechargeAmountCHF}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, rechargeAmountCHF: Number(e.target.value) }))
|
||||
}
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Max. Nachladungen / Monat</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={formData.rechargeMaxPerMonth}
|
||||
onChange={(e) =>
|
||||
setFormData(prev => ({ ...prev, rechargeMaxPerMonth: Math.max(0, Math.floor(Number(e.target.value))) }))
|
||||
}
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -202,28 +221,15 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
|||
// ============================================================================
|
||||
|
||||
interface CreditAdderProps {
|
||||
settings: BillingSettings | null;
|
||||
accounts: AccountSummary[];
|
||||
users: MandateUserSummary[];
|
||||
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
|
||||
}
|
||||
|
||||
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
||||
const [amount, setAmount] = useState<string>('');
|
||||
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
|
||||
|
||||
const accountsByUserId = accounts
|
||||
.filter(acc => acc.accountType === 'USER')
|
||||
.reduce((map, acc) => {
|
||||
if (acc.userId) map[acc.userId] = acc;
|
||||
return map;
|
||||
}, {} as Record<string, AccountSummary>);
|
||||
|
||||
const _handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const numAmount = parseFloat(amount);
|
||||
|
|
@ -236,7 +242,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
setMessage(null);
|
||||
|
||||
try {
|
||||
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
||||
await onAddCredit(undefined, numAmount, description);
|
||||
const label = numAmount > 0
|
||||
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
||||
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
||||
|
|
@ -260,31 +266,6 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
)}
|
||||
|
||||
<form onSubmit={_handleSubmit}>
|
||||
{isPrepayUser && (
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Benutzer</label>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">-- Benutzer wählen --</option>
|
||||
{users.map((user) => {
|
||||
const account = accountsByUserId[user.id];
|
||||
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
|
||||
return (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName || user.username || user.id}{balanceInfo}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>Betrag (CHF)</label>
|
||||
|
|
@ -313,7 +294,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
<button
|
||||
type="submit"
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
||||
disabled={saving || !amount}
|
||||
>
|
||||
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
||||
</button>
|
||||
|
|
@ -328,11 +309,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
|
||||
interface AccountsOverviewProps {
|
||||
accounts: AccountSummary[];
|
||||
users: MandateUserSummary[];
|
||||
/** Kept for call-site compatibility; only mandate pool accounts are shown. */
|
||||
users?: MandateUserSummary[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, loading }) => {
|
||||
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
|
|
@ -340,19 +322,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
|||
}).format(amount);
|
||||
};
|
||||
|
||||
// Build a lookup map: userId -> display name
|
||||
const _userNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const user of users) {
|
||||
const displayName = user.displayName
|
||||
|| [user.firstName, user.lastName].filter(Boolean).join(' ')
|
||||
|| user.username
|
||||
|| user.id;
|
||||
map.set(user.id, displayName);
|
||||
}
|
||||
return map;
|
||||
}, [users]);
|
||||
|
||||
const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
|
||||
}
|
||||
|
|
@ -360,16 +331,19 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
|||
if (accounts.length === 0) {
|
||||
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
||||
}
|
||||
|
||||
if (poolAccounts.length === 0) {
|
||||
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminSection}>
|
||||
<h3>Konten</h3>
|
||||
<div className={styles.accountsGrid}>
|
||||
{accounts.map((account) => (
|
||||
{poolAccounts.map((account) => (
|
||||
<div key={account.id} className={styles.accountCard}>
|
||||
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
|
||||
<h4>Mandanten-Konto</h4>
|
||||
<div className={styles.accountInfo}>
|
||||
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
||||
</div>
|
||||
|
|
@ -782,9 +756,6 @@ export const BillingAdmin: React.FC = () => {
|
|||
<>
|
||||
{isSysAdmin && (
|
||||
<CreditAdder
|
||||
settings={settings}
|
||||
accounts={accounts}
|
||||
users={users}
|
||||
onAddCredit={_handleAddCredit}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
|||
}).format(amount);
|
||||
};
|
||||
|
||||
const getBillingModelLabel = (model: string) => {
|
||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||
return 'Prepaid (Mandant)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
|
||||
|
|
@ -38,7 +33,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
|||
>
|
||||
<div className={styles.balanceHeader}>
|
||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
|
||||
</div>
|
||||
<div className={styles.balanceAmount}>
|
||||
{formatCurrency(balance.balance)}
|
||||
|
|
|
|||
|
|
@ -8,19 +8,16 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import api from '../../api';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
||||
import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
import { UserTransaction } from '../../api/billingApi';
|
||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
|
||||
|
||||
// ============================================================================
|
||||
// HELPER: Currency formatter
|
||||
// ============================================================================
|
||||
|
|
@ -46,34 +43,51 @@ interface ViewStatistics {
|
|||
timeSeries: Array<{ date: string; cost: number; count: number }>;
|
||||
}
|
||||
|
||||
interface DataVolumeInfo {
|
||||
mandateId: string;
|
||||
mandateName: string;
|
||||
usedMB: number;
|
||||
filesMB: number;
|
||||
ragIndexMB: number;
|
||||
maxDataVolumeMB: number | null;
|
||||
percentUsed: number | null;
|
||||
warning: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BALANCE CARD COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
interface BalanceCardProps {
|
||||
balance: BillingBalance;
|
||||
onCheckout?: (mandateId: string, amount: number) => void;
|
||||
checkoutLoading?: boolean;
|
||||
onOpenMandateAdmin?: (mandateId: string) => void;
|
||||
}
|
||||
|
||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => {
|
||||
const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]);
|
||||
const [showCheckout, setShowCheckout] = useState(false);
|
||||
|
||||
const _getBillingModelLabel = (model: string) => {
|
||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||
return 'Prepaid (Mandant)';
|
||||
};
|
||||
|
||||
// Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing.
|
||||
const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER';
|
||||
const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE';
|
||||
|
||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
|
||||
return (
|
||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||
<div className={styles.balanceHeader}>
|
||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
|
||||
{onOpenMandateAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.mandateName}
|
||||
onClick={() => onOpenMandateAdmin(balance.mandateId)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{balance.mandateName}
|
||||
</button>
|
||||
) : (
|
||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.balanceAmount}>
|
||||
{_formatCurrency(balance.balance)}
|
||||
|
|
@ -83,60 +97,17 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
|||
Niedriges Guthaben
|
||||
</div>
|
||||
)}
|
||||
{isMandatePrepaidPool && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.45,
|
||||
opacity: 0.75,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
||||
</p>
|
||||
)}
|
||||
{canStripeTopUpHere && onCheckout && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
{!showCheckout ? (
|
||||
<button
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
|
||||
onClick={() => setShowCheckout(true)}
|
||||
>
|
||||
Budget laden mit Kreditkarte
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedAmount}
|
||||
onChange={(e) => setSelectedAmount(Number(e.target.value))}
|
||||
style={{ flex: 1, fontSize: '13px' }}
|
||||
>
|
||||
{STRIPE_AMOUNT_PRESETS.map((preset) => (
|
||||
<option key={preset} value={preset}>{preset} CHF</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
|
||||
disabled={checkoutLoading}
|
||||
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
|
||||
>
|
||||
{checkoutLoading ? 'Laden...' : 'Zahlen'}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.button} ${styles.buttonSecondary || ''}`}
|
||||
style={{ fontSize: '13px', padding: '6px 12px' }}
|
||||
onClick={() => setShowCheckout(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.45,
|
||||
opacity: 0.75,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -329,9 +300,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
|||
export const BillingDataView: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { request } = useApiRequest();
|
||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const _openMandateBillingAdmin = useCallback((mandateId: string) => {
|
||||
navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`);
|
||||
}, [navigate]);
|
||||
|
||||
// Scope filter: 'personal' | 'all' | mandateId
|
||||
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
||||
|
|
@ -399,58 +373,20 @@ export const BillingDataView: React.FC = () => {
|
|||
setCheckoutMessage(null);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const _handleCheckout = useCallback(async (mandateId: string, amount: number) => {
|
||||
setCheckoutLoading(true);
|
||||
setCheckoutMessage(null);
|
||||
try {
|
||||
const currentUser = getUserDataCache();
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.delete('success');
|
||||
currentUrl.searchParams.delete('canceled');
|
||||
currentUrl.searchParams.delete('session_id');
|
||||
currentUrl.hash = '';
|
||||
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
|
||||
const result = await createCheckoutSession(request, mandateId, {
|
||||
userId: currentUser?.id,
|
||||
amount,
|
||||
returnUrl,
|
||||
});
|
||||
if (result?.redirectUrl) {
|
||||
window.location.href = result.redirectUrl;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' });
|
||||
setCheckoutLoading(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
// All user balances (for admin overview cards)
|
||||
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
||||
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
||||
|
||||
// Statistics state (shared by Overview and Statistics tabs)
|
||||
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
|
||||
// Storage volume state (for Statistics tab)
|
||||
const [storageData, setStorageData] = useState<DataVolumeInfo[]>([]);
|
||||
const [storageLoading, setStorageLoading] = useState(false);
|
||||
|
||||
// Transactions state (for Transactions tab)
|
||||
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
||||
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
||||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||
|
||||
// Load all user balances for admin overview
|
||||
const _loadAllUserBalances = useCallback(async () => {
|
||||
try {
|
||||
setAllUserBalancesLoading(true);
|
||||
const response = await api.get('/api/billing/view/users/balances');
|
||||
setAllUserBalances(Array.isArray(response.data) ? response.data : []);
|
||||
} catch {
|
||||
setAllUserBalances([]);
|
||||
} finally {
|
||||
setAllUserBalancesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load aggregated statistics from the view/statistics route
|
||||
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
||||
try {
|
||||
|
|
@ -486,15 +422,47 @@ export const BillingDataView: React.FC = () => {
|
|||
_loadViewStatistics(period, year, month);
|
||||
}, [_loadViewStatistics]);
|
||||
|
||||
// Initial data load: load statistics when overview or statistics tab becomes active
|
||||
// Load storage volume for all accessible mandates
|
||||
const _loadStorageData = useCallback(async () => {
|
||||
const mandateIds = new Set<string>();
|
||||
for (const b of balances) {
|
||||
if (selectedScope === 'personal' || selectedScope === 'all' || selectedScope === b.mandateId) {
|
||||
mandateIds.add(b.mandateId);
|
||||
}
|
||||
}
|
||||
if (mandateIds.size === 0) {
|
||||
setStorageData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setStorageLoading(true);
|
||||
try {
|
||||
const mandateNameMap = new Map(balances.map(b => [b.mandateId, b.mandateName]));
|
||||
const results = await Promise.all(
|
||||
Array.from(mandateIds).map(async (mid) => {
|
||||
try {
|
||||
const resp = await api.get(`/api/subscription/data-volume/${mid}`);
|
||||
return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
setStorageData(results.filter((r): r is DataVolumeInfo => r !== null));
|
||||
} catch {
|
||||
setStorageData([]);
|
||||
} finally {
|
||||
setStorageLoading(false);
|
||||
}
|
||||
}, [balances, selectedScope]);
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
if (activeTab === 'overview' || activeTab === 'statistics') {
|
||||
_loadViewStatistics('month', new Date().getFullYear());
|
||||
_loadStorageData();
|
||||
}
|
||||
if (activeTab === 'overview') {
|
||||
_loadAllUserBalances();
|
||||
}
|
||||
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
|
||||
}, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]);
|
||||
|
||||
// Load transactions with pagination support
|
||||
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
||||
|
|
@ -644,12 +612,6 @@ export const BillingDataView: React.FC = () => {
|
|||
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
|
||||
? balances
|
||||
: balances.filter(b => b.mandateId === selectedScope);
|
||||
|
||||
const filteredUserBalances = selectedScope === 'personal'
|
||||
? [] // personal view: only own balance cards, no other users
|
||||
: selectedScope === 'all'
|
||||
? allUserBalances
|
||||
: allUserBalances.filter(ub => ub.mandateId === selectedScope);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -666,35 +628,60 @@ export const BillingDataView: React.FC = () => {
|
|||
<BalanceCard
|
||||
key={balance.mandateId}
|
||||
balance={balance}
|
||||
onCheckout={_handleCheckout}
|
||||
checkoutLoading={checkoutLoading}
|
||||
onOpenMandateAdmin={_openMandateBillingAdmin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* All User Balance Cards (mandate/all scope) */}
|
||||
{filteredUserBalances.length > 0 && (
|
||||
{/* Storage quick info */}
|
||||
{!storageLoading && storageData.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
|
||||
{allUserBalancesLoading ? (
|
||||
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div>
|
||||
) : (
|
||||
<div className={styles.balanceGrid}>
|
||||
{filteredUserBalances.map((ub, idx) => (
|
||||
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
|
||||
<div className={styles.balanceHeader}>
|
||||
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
|
||||
<span className={styles.billingModel}>{ub.mandateName}</span>
|
||||
</div>
|
||||
<div className={styles.balanceAmount}>
|
||||
{_formatCurrency(ub.balance || 0)}
|
||||
<h2 className={styles.sectionTitle}>Speicher</h2>
|
||||
<div className={styles.balanceGrid}>
|
||||
{storageData.map((sv) => {
|
||||
const pct = sv.percentUsed ?? 0;
|
||||
const barColor = pct >= 90
|
||||
? 'var(--color-error, #ef4444)'
|
||||
: pct >= 70
|
||||
? 'var(--color-warning, #f59e0b)'
|
||||
: 'var(--primary-color, #3b82f6)';
|
||||
return (
|
||||
<div key={sv.mandateId} className={styles.balanceCard}>
|
||||
<h3 className={styles.mandateName}>{sv.mandateName}</h3>
|
||||
<div className={styles.balanceAmount} style={{ fontSize: '1.3rem' }}>
|
||||
{formatBinaryDataSizeFromMebibytes(sv.usedMB)}
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', marginLeft: '6px' }}>
|
||||
/ {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'}
|
||||
</span>
|
||||
</div>
|
||||
{sv.maxDataVolumeMB != null && (
|
||||
<div style={{
|
||||
height: '6px',
|
||||
background: 'var(--bg-secondary, #2a2a2a)',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
marginTop: '10px',
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
background: barColor,
|
||||
borderRadius: '3px',
|
||||
minWidth: pct > 0 ? '3px' : '0',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{sv.warning && (
|
||||
<div className={styles.warningBadge} style={{ marginTop: '8px' }}>
|
||||
Speicher knapp
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
|
@ -716,18 +703,104 @@ export const BillingDataView: React.FC = () => {
|
|||
{/* Tab: Statistik (Dashboard) */}
|
||||
{/* ================================================================ */}
|
||||
{activeTab === 'statistics' && (
|
||||
<section className={styles.section}>
|
||||
<FormGeneratorReport
|
||||
title="Nutzungsstatistik"
|
||||
subtitle="Detaillierte Analyse der AI-Nutzung"
|
||||
periodSelector={periodSelectorConfig}
|
||||
onFilterChange={_handleStatsFilterChange}
|
||||
loading={statsLoading}
|
||||
sections={statisticsSections}
|
||||
noDataMessage="Keine Statistiken verfügbar"
|
||||
currencyCode="CHF"
|
||||
/>
|
||||
</section>
|
||||
<>
|
||||
{/* Storage volume section */}
|
||||
<section className={styles.section}>
|
||||
<div className={styles.statisticsChart}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||
Speicherverbrauch
|
||||
</h3>
|
||||
{storageLoading ? (
|
||||
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
|
||||
) : storageData.length === 0 ? (
|
||||
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
{storageData.map((sv) => {
|
||||
const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB);
|
||||
const maxLabel = sv.maxDataVolumeMB != null
|
||||
? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB)
|
||||
: 'unbegrenzt';
|
||||
const pct = sv.percentUsed ?? 0;
|
||||
const barColor = pct >= 90
|
||||
? 'var(--color-error, #ef4444)'
|
||||
: pct >= 70
|
||||
? 'var(--color-warning, #f59e0b)'
|
||||
: 'var(--primary-color, #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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ================================================================ */}
|
||||
|
|
|
|||
|
|
@ -38,20 +38,14 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
|||
}).format(amount);
|
||||
};
|
||||
|
||||
const getBillingModelLabel = (model: string) => {
|
||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
||||
return 'Prepaid (Mandant)';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className={styles.transactionsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mandant</th>
|
||||
<th>Billing-Modell</th>
|
||||
<th>Anzahl Benutzer</th>
|
||||
<th>Standard-Guthaben</th>
|
||||
<th>Warnschwelle (%)</th>
|
||||
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
|
|
@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
|||
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
|
||||
>
|
||||
<td>{balance.mandateName || balance.mandateId}</td>
|
||||
<td>{getBillingModelLabel(balance.billingModel)}</td>
|
||||
<td>{balance.userCount}</td>
|
||||
<td>{formatCurrency(balance.defaultUserCredit)}</td>
|
||||
<td>{balance.warningThresholdPercent}%</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
|
||||
<td>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
|
||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
|
|||
NONE: '—',
|
||||
};
|
||||
|
||||
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
|
||||
const storageOverageChfPerGbMonth = 0.5;
|
||||
|
||||
// ============================================================================
|
||||
// Plan Card
|
||||
// ============================================================================
|
||||
|
|
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
|||
<div style={{ fontSize: '0.85rem' }}>
|
||||
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary, #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>
|
||||
)}
|
||||
|
||||
|
|
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
|||
{plan.trialDays} Tage kostenlos
|
||||
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
||||
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
||||
{(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && (
|
||||
<>
|
||||
{plan.maxDataVolumeMB != null && (
|
||||
<> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
|
||||
)}
|
||||
{(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}</>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -214,6 +239,20 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
|||
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
||||
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
||||
)}
|
||||
{plan && (
|
||||
<>
|
||||
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span>
|
||||
<span>
|
||||
Speicher (inkl.):{' '}
|
||||
{plan.maxDataVolumeMB == null
|
||||
? 'unbegrenzt'
|
||||
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||
</span>
|
||||
<span style={{ gridColumn: '1 / -1' }}>
|
||||
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
|
|||
{
|
||||
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
||||
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
||||
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
|
||||
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
|
||||
variant: 'danger',
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ export const AutomationDefinitionsView: React.FC = () => {
|
|||
|
||||
const columns = useMemo(() => {
|
||||
const hiddenColumns = [
|
||||
'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt',
|
||||
'id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt',
|
||||
'template', 'executionLogs', 'placeholders',
|
||||
'_createdByUserName', 'mandateName', 'featureInstanceName',
|
||||
'sysCreatedByUserName', 'mandateName', 'featureInstanceName',
|
||||
];
|
||||
const attrColumns = (attributes || [])
|
||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||
|
|
@ -130,7 +130,7 @@ export const AutomationDefinitionsView: React.FC = () => {
|
|||
const enrichedColumns = [
|
||||
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
|
||||
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
|
||||
{ key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
|
||||
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
|
||||
];
|
||||
return [...attrColumns, ...enrichedColumns];
|
||||
}, [attributes]);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const AutomationTemplatesView: React.FC = () => {
|
|||
value ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--info-color, #3182ce)', color: '#fff' }}><FaLock style={{ fontSize: '0.625rem' }} /> System</span>
|
||||
: <span style={{ fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--success-color, #38a169)', color: '#fff' }}>Instanz</span>
|
||||
},
|
||||
{ key: '_createdByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
|
||||
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
|
||||
], []);
|
||||
|
||||
const handleEditClick = async (template: AutomationTemplate) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,56 @@
|
|||
/* Outer flex layout: UDB sidebar + main dossier */
|
||||
.dossierLayout {
|
||||
display: flex;
|
||||
height: calc(100vh - 140px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.udbSidebar {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card, #fff);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: width 0.2s, min-width 0.2s;
|
||||
}
|
||||
|
||||
.udbSidebarCollapsed {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.udbToggle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card, #fff);
|
||||
cursor: pointer;
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-secondary, #888);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.udbToggle:hover {
|
||||
background: var(--bg-hover, #f5f5f5);
|
||||
color: var(--primary-color, #F25843);
|
||||
}
|
||||
|
||||
.dossier {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 140px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,48 +1,54 @@
|
|||
/**
|
||||
* CommCoach Dossier View (Main View)
|
||||
*
|
||||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents.
|
||||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores.
|
||||
* Voice first, always with text fallback.
|
||||
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import api from '../../../api';
|
||||
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||
import {
|
||||
getDossierExportUrl, getSessionExportUrl,
|
||||
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
|
||||
getScoreHistoryApi, getPersonasApi,
|
||||
type CoachingDocument, type CoachingPersona,
|
||||
type CoachingPersona,
|
||||
} from '../../../api/commcoachApi';
|
||||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
import styles from './CommcoachDossierView.module.css';
|
||||
import { useVoiceController } from './useVoiceController';
|
||||
|
||||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
|
||||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||||
|
||||
export const CommcoachDossierView: React.FC = () => {
|
||||
const coach = useCommcoach();
|
||||
const { request } = useApiRequest();
|
||||
const instanceId = useInstanceId();
|
||||
const mandateId = useMandateId();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||||
const [showNewContext, setShowNewContext] = useState(false);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [newCategory, setNewCategory] = useState('custom');
|
||||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||
|
||||
const _udbContext: UdbContext | null = instanceId
|
||||
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
||||
: null;
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sendMessageRef = useRef(coach.sendMessage);
|
||||
sendMessageRef.current = coach.sendMessage;
|
||||
|
|
@ -82,27 +88,14 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
}
|
||||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||
|
||||
// Load documents, scores, personas when context changes
|
||||
// Load scores, personas when context changes
|
||||
useEffect(() => {
|
||||
if (!instanceId || !coach.selectedContextId) return;
|
||||
getDocumentsApi(request, instanceId, coach.selectedContextId)
|
||||
.then(d => setDocuments(d))
|
||||
.catch(() => {});
|
||||
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
||||
.then(h => setScoreHistory(h))
|
||||
.catch(() => {});
|
||||
}, [instanceId, request, coach.selectedContextId]);
|
||||
|
||||
useEffect(() => {
|
||||
coach.onDocumentCreatedRef.current = (doc) => {
|
||||
setDocuments(prev => {
|
||||
if (prev.some(d => d.id === doc.id)) return prev;
|
||||
return [doc, ...prev];
|
||||
});
|
||||
};
|
||||
return () => { coach.onDocumentCreatedRef.current = null; };
|
||||
}, [coach.onDocumentCreatedRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
getPersonasApi(request, instanceId)
|
||||
|
|
@ -118,6 +111,15 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
}
|
||||
}, [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 handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||
|
|
@ -144,46 +146,6 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
coach.selectContext(contextId, { skipSessionResume: true });
|
||||
}, [coach]);
|
||||
|
||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !instanceId || !coach.selectedContextId) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
|
||||
setDocuments(prev => [doc, ...prev]);
|
||||
} catch { /* upload failed */ } finally {
|
||||
setUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
}, [instanceId, coach.selectedContextId]);
|
||||
|
||||
const handleDeleteDocument = useCallback(async (docId: string) => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
await deleteDocumentApi(request, instanceId, docId);
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId));
|
||||
} catch { /* delete failed */ }
|
||||
}, [instanceId, request]);
|
||||
|
||||
const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => {
|
||||
if (!doc.fileRef) return;
|
||||
try {
|
||||
const response = await api.get(`/api/files/${doc.fileRef}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(response.data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = doc.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddTask = useCallback(async () => {
|
||||
if (!newTaskTitle.trim()) return;
|
||||
await coach.addTask(newTaskTitle);
|
||||
|
|
@ -195,7 +157,30 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dossier}>
|
||||
<div className={styles.dossierLayout}>
|
||||
{/* UDB Sidebar */}
|
||||
{_udbContext && (
|
||||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||
<button
|
||||
className={styles.udbToggle}
|
||||
onClick={() => setUdbCollapsed(v => !v)}
|
||||
title={udbCollapsed ? 'Seitenleiste einblenden' : 'Seitenleiste ausblenden'}
|
||||
>
|
||||
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||
</button>
|
||||
{!udbCollapsed && (
|
||||
<UnifiedDataBar
|
||||
context={_udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={styles.dossier}>
|
||||
{/* Context Selector */}
|
||||
<div className={styles.contextSelector}>
|
||||
{coach.contexts.map(ctx => (
|
||||
|
|
@ -286,13 +271,13 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
|
||||
{/* Tab Navigation */}
|
||||
<div className={styles.tabs}>
|
||||
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
|
||||
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{_tabLabel(tab, coach, documents)}
|
||||
{_tabLabel(tab, coach)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -546,41 +531,9 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* DOCUMENTS TAB */}
|
||||
{/* ============================================================ */}
|
||||
{activeTab === 'documents' && (
|
||||
<div className={styles.tabContent}>
|
||||
<div className={styles.addTaskRow}>
|
||||
<label className={styles.uploadLabel}>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
|
||||
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
|
||||
</label>
|
||||
</div>
|
||||
{documents.length === 0 ? (
|
||||
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
|
||||
) : (
|
||||
<div className={styles.documentList}>
|
||||
{documents.map(doc => (
|
||||
<div key={doc.id} className={styles.documentItem}>
|
||||
<div className={styles.documentInfo}>
|
||||
<div className={styles.documentName}>{doc.fileName}</div>
|
||||
<div className={styles.documentMeta}>
|
||||
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
|
||||
</div>
|
||||
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
|
||||
</div>
|
||||
<div className={styles.documentActions}>
|
||||
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
|
||||
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
|
||||
|
||||
{/* #region agent log */}
|
||||
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
||||
<button
|
||||
|
|
@ -595,6 +548,7 @@ export const CommcoachDossierView: React.FC = () => {
|
|||
</div>
|
||||
{/* #endregion */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -607,13 +561,12 @@ function _categoryIcon(category: string): string {
|
|||
return icons[category] || '*';
|
||||
}
|
||||
|
||||
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
|
||||
function _tabLabel(tab: TabKey, coach: any): string {
|
||||
switch (tab) {
|
||||
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
||||
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
||||
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
||||
case 'scores': return `Bewertungen (${coach.scores.length})`;
|
||||
case 'documents': return `Dokumente (${documents.length})`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -634,12 +587,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
|||
return Object.values(groups);
|
||||
}
|
||||
|
||||
function _formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function _dimensionLabel(dim: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
/**
|
||||
* CommCoach Settings View
|
||||
*
|
||||
* User profile settings: voice preferences, reminders, email notifications.
|
||||
*
|
||||
* Coaching-specific settings: reminders, email notifications, stats.
|
||||
* Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache").
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import {
|
||||
getProfileApi, updateProfileApi,
|
||||
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
|
||||
type CoachingUserProfile,
|
||||
} from '../../../api/commcoachApi';
|
||||
import styles from './CommcoachSettingsView.module.css';
|
||||
|
|
@ -19,16 +20,11 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
const instanceId = useInstanceId();
|
||||
|
||||
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
||||
const [languages, setLanguages] = useState<any[]>([]);
|
||||
const [voices, setVoices] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [language, setLanguage] = useState('de-DE');
|
||||
const [voiceId, setVoiceId] = useState('');
|
||||
const [reminderEnabled, setReminderEnabled] = useState(false);
|
||||
const [reminderTime, setReminderTime] = useState('09:00');
|
||||
const [emailEnabled, setEmailEnabled] = useState(true);
|
||||
|
|
@ -38,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [profileData, languagesData] = await Promise.all([
|
||||
getProfileApi(request, instanceId),
|
||||
getVoiceLanguagesApi(request, instanceId),
|
||||
]);
|
||||
const profileData = await getProfileApi(request, instanceId);
|
||||
setProfile(profileData);
|
||||
setLanguages(languagesData || []);
|
||||
|
||||
if (profileData) {
|
||||
setLanguage(profileData.preferredLanguage || 'de-DE');
|
||||
setVoiceId(profileData.preferredVoice || '');
|
||||
setReminderEnabled(profileData.dailyReminderEnabled || false);
|
||||
setReminderTime(profileData.dailyReminderTime || '09:00');
|
||||
setEmailEnabled(profileData.emailSummaryEnabled !== false);
|
||||
}
|
||||
|
||||
const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE');
|
||||
setVoices(voicesData || []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Laden');
|
||||
} finally {
|
||||
|
|
@ -64,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
loadData();
|
||||
}, [request, instanceId]);
|
||||
|
||||
const handleLanguageChange = useCallback(async (newLang: string) => {
|
||||
setLanguage(newLang);
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const voicesData = await getVoiceVoicesApi(request, instanceId, newLang);
|
||||
setVoices(voicesData || []);
|
||||
setVoiceId('');
|
||||
} catch { /* ignore */ }
|
||||
}, [request, instanceId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setSaving(true);
|
||||
|
|
@ -81,8 +57,6 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
setSuccess(null);
|
||||
try {
|
||||
const updated = await updateProfileApi(request, instanceId, {
|
||||
preferredLanguage: language,
|
||||
preferredVoice: voiceId || null,
|
||||
dailyReminderEnabled: reminderEnabled,
|
||||
dailyReminderTime: reminderTime,
|
||||
emailSummaryEnabled: emailEnabled,
|
||||
|
|
@ -95,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]);
|
||||
|
||||
const handleTestVoice = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testVoiceApi(request, instanceId, {
|
||||
language,
|
||||
voiceId: voiceId || undefined,
|
||||
});
|
||||
if (result.success && result.audio) {
|
||||
const audioData = `data:audio/mp3;base64,${result.audio}`;
|
||||
const audio = new Audio(audioData);
|
||||
audio.play();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError('Sprachtest fehlgeschlagen');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [request, instanceId, language, voiceId]);
|
||||
}, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]);
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
||||
|
|
@ -128,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => {
|
|||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
{/* Voice Settings */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Sprache und Stimme</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Sprache</label>
|
||||
<select className={styles.select} value={language} onChange={e => handleLanguageChange(e.target.value)}>
|
||||
{languages.length > 0 ? (
|
||||
languages.map((lang: any) => (
|
||||
<option key={lang.code || lang} value={lang.code || lang}>
|
||||
{lang.name || lang.code || lang}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="de-DE">Deutsch</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="fr-FR">Francais</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Stimme</label>
|
||||
<div className={styles.voiceRow}>
|
||||
<select className={styles.select} value={voiceId} onChange={e => setVoiceId(e.target.value)}>
|
||||
<option value="">Standard</option>
|
||||
{voices.map((v: any) => (
|
||||
<option key={v.name || v} value={v.name || v}>
|
||||
{v.displayName || v.name || v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className={styles.testBtn} onClick={handleTestVoice} disabled={testing}>
|
||||
{testing ? 'Teste...' : 'Testen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={styles.sectionTitle}>Stimme & Sprache</h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||
Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.
|
||||
</p>
|
||||
<Link to="/settings" onClick={() => {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
|
||||
Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Reminder Settings */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Erinnerungen</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reminderEnabled}
|
||||
onChange={e => setReminderEnabled(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
|
||||
Taegliche Coaching-Erinnerung per E-Mail
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{reminderEnabled && (
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Uhrzeit</label>
|
||||
<input
|
||||
type="time"
|
||||
className={styles.input}
|
||||
value={reminderTime}
|
||||
onChange={e => setReminderTime(e.target.value)}
|
||||
/>
|
||||
<input type="time" className={styles.input} value={reminderTime} onChange={e => setReminderTime(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailEnabled}
|
||||
onChange={e => setEmailEnabled(e.target.checked)}
|
||||
/>
|
||||
<input type="checkbox" checked={emailEnabled} onChange={e => setEmailEnabled(e.target.checked)} />
|
||||
Session-Zusammenfassung per E-Mail senden
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{profile && (
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Statistik</h3>
|
||||
<div className={styles.statsGrid}>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.totalSessions}</span>
|
||||
<span className={styles.statLabel}>Sessions gesamt</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.totalMinutes}</span>
|
||||
<span className={styles.statLabel}>Minuten gesamt</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.streakDays}</span>
|
||||
<span className={styles.statLabel}>Aktueller Streak</span>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<span className={styles.statValue}>{profile.longestStreak}</span>
|
||||
<span className={styles.statLabel}>Laengster Streak</span>
|
||||
</div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalSessions}</span><span className={styles.statLabel}>Sessions gesamt</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalMinutes}</span><span className={styles.statLabel}>Minuten gesamt</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.streakDays}</span><span className={styles.statLabel}>Aktueller Streak</span></div>
|
||||
<div className={styles.statItem}><span className={styles.statValue}>{profile.longestStreak}</span><span className={styles.statLabel}>Laengster Streak</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@
|
|||
*
|
||||
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
||||
* Google Streaming STT handles silence detection natively.
|
||||
* STT language is loaded from central voice preferences (/api/voice/preferences).
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||
import api from '../../../api';
|
||||
|
||||
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
||||
|
||||
|
|
@ -30,6 +32,8 @@ export interface VoiceControllerCallbacks {
|
|||
onInterimText?: (text: string) => void;
|
||||
}
|
||||
|
||||
const _DEFAULT_STT_LANGUAGE = 'de-DE';
|
||||
|
||||
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
||||
const [state, setState] = useState<VoiceState>('idle');
|
||||
const [muted, setMuted] = useState(false);
|
||||
|
|
@ -38,6 +42,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
|||
const cbRef = useRef(callbacks);
|
||||
cbRef.current = callbacks;
|
||||
|
||||
const sttLanguageRef = useRef<string>(_DEFAULT_STT_LANGUAGE);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get('/api/voice/preferences').then((res) => {
|
||||
if (cancelled) return;
|
||||
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
|
||||
if (lang) sttLanguageRef.current = lang;
|
||||
}).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const _dlog = useCallback((tag: string, info?: string) => {
|
||||
const t = new Date();
|
||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
||||
|
|
@ -68,16 +84,20 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
|||
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
||||
});
|
||||
|
||||
const _startStream = useCallback(() => {
|
||||
return voiceStream.start(sttLanguageRef.current);
|
||||
}, [voiceStream]);
|
||||
|
||||
const activate = useCallback(async () => {
|
||||
if (stateRef.current !== 'idle') return;
|
||||
_setState('listening');
|
||||
try {
|
||||
await voiceStream.start('de-DE');
|
||||
await _startStream();
|
||||
} catch (err) {
|
||||
_dlog('MIC-ERR', String(err));
|
||||
_setState('idle');
|
||||
}
|
||||
}, [_setState, voiceStream, _dlog]);
|
||||
}, [_setState, _startStream, _dlog]);
|
||||
|
||||
const deactivate = useCallback(() => {
|
||||
voiceStream.stop();
|
||||
|
|
@ -94,15 +114,15 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
|||
const ttsPaused = useCallback(() => {
|
||||
if (stateRef.current !== 'botSpeaking') return;
|
||||
_setState('interrupted');
|
||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||
}, [_setState, voiceStream, _dlog]);
|
||||
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||
}, [_setState, _startStream, _dlog]);
|
||||
|
||||
const ttsEnded = useCallback(() => {
|
||||
const cur = stateRef.current;
|
||||
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
||||
_setState('listening');
|
||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||
}, [_setState, voiceStream, _dlog]);
|
||||
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||
}, [_setState, _startStream, _dlog]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const cur = stateRef.current;
|
||||
|
|
@ -110,13 +130,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
|||
if (mutedRef.current) {
|
||||
_setMuted(false);
|
||||
if (cur === 'listening' || cur === 'interrupted') {
|
||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
||||
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||
}
|
||||
} else {
|
||||
_setMuted(true);
|
||||
voiceStream.stop();
|
||||
}
|
||||
}, [_setMuted, voiceStream, _dlog]);
|
||||
}, [_setMuted, _startStream, voiceStream, _dlog]);
|
||||
|
||||
return {
|
||||
state,
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
};
|
||||
|
||||
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));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
};
|
||||
|
||||
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));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
|
||||
// Form attributes (exclude system fields)
|
||||
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));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
if (!attributes || attributes.length === 0) return [];
|
||||
|
||||
// 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
|
||||
.filter((attr: any) => !excludedFields.includes(attr.name))
|
||||
|
|
@ -127,7 +127,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
|
||||
// Form attributes (exclude system fields)
|
||||
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));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
const positionColumnOrder = [
|
||||
'_documentRefs', // Belege (download icons)
|
||||
'_syncStatus', // Sync-Status
|
||||
'_createdAt', // Erstellt am
|
||||
'sysCreatedAt', // Erstellt am
|
||||
'valuta', // Valuta date
|
||||
'tags',
|
||||
'company',
|
||||
|
|
@ -372,7 +372,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
|
||||
// Form attributes (exclude system fields)
|
||||
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));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react';
|
|||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import api from '../../../api';
|
||||
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||
import type { AgentProgress, FileEditProposal } from './useWorkspace';
|
||||
|
||||
|
|
@ -147,6 +148,44 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
|||
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>
|
||||
|
|
@ -301,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
|
|||
|
||||
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
|
||||
const icon = _getFileIcon(ext);
|
||||
const sizeLabel = doc.fileSize
|
||||
? doc.fileSize > 1024 * 1024
|
||||
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
|
||||
: `${(doc.fileSize / 1024).toFixed(1)} KB`
|
||||
: '';
|
||||
const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
{conv.status === 'archived' ? (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); _handleReactivate(conv.id); }}
|
||||
style={{ ..._actionBtnStyle, color: '#4caf50' }}
|
||||
title="Reaktivieren"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); _handleArchive(conv.id); }}
|
||||
style={_actionBtnStyle}
|
||||
title="Archivieren"
|
||||
>
|
||||
📦
|
||||
</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"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmDeleteId(null); }}
|
||||
style={{ ..._actionBtnStyle, color: '#fff', fontSize: 13 }}
|
||||
title="Abbrechen"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setConfirmDeleteId(conv.id); }}
|
||||
style={{ ..._actionBtnStyle, color: '#d32f2f' }}
|
||||
title="Loeschen"
|
||||
>
|
||||
🗑
|
||||
</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 }}
|
||||
>
|
||||
<
|
||||
</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 }}
|
||||
>
|
||||
>
|
||||
</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',
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../../../api';
|
||||
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||
import type { WorkspaceFile } from './useWorkspace';
|
||||
|
||||
interface FilePreviewProps {
|
||||
|
|
@ -76,7 +77,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
|
|||
</div>
|
||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
||||
<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>}
|
||||
</div>
|
||||
{file.description && (
|
||||
|
|
@ -156,8 +157,3 @@ function _isTextMime(mime: string): boolean {
|
|||
return textTypes.includes(mime);
|
||||
}
|
||||
|
||||
function _formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
|
|
|||
464
src/pages/views/workspace/NeutralizationPanel.tsx
Normal file
464
src/pages/views/workspace/NeutralizationPanel.tsx
Normal 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 ↔ 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;
|
||||
|
|
@ -15,6 +15,7 @@ import type { editor as monacoEditor } from 'monaco-editor';
|
|||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
|
||||
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
|
||||
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||
|
||||
function _getMonacoLanguage(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
|
|
@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string {
|
|||
return langMap[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
function _formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export const WorkspaceEditorPage: React.FC = () => {
|
||||
const instanceId = useInstanceId() || '';
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => {
|
|||
}}>
|
||||
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
|
||||
<span>{activeEdit.fileName}</span>
|
||||
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span>
|
||||
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span>
|
||||
<span>Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)}</span>
|
||||
<span>Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import styles from './WorkspaceSettings.module.css';
|
||||
import styles from './WorkspaceGeneralSettings.module.css';
|
||||
|
||||
interface GeneralSettingsProps {
|
||||
instanceId: string;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
||||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||
import { getPageIcon } from '../../../config/pageRegistry';
|
||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
||||
|
|
@ -38,7 +39,7 @@ interface TreeItemDrop {
|
|||
|
||||
interface WorkspaceInputProps {
|
||||
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;
|
||||
onStop: () => void;
|
||||
files: WorkspaceFile[];
|
||||
|
|
@ -48,11 +49,13 @@ interface WorkspaceInputProps {
|
|||
onRemovePendingFile?: (fileId: string) => void;
|
||||
onFileUploadClick?: () => void;
|
||||
uploading?: boolean;
|
||||
selectedProviders?: string[];
|
||||
onProvidersChange?: (providers: string[]) => void;
|
||||
providerSelection?: ProviderSelection;
|
||||
onProviderSelectionChange?: (selection: ProviderSelection) => void;
|
||||
isMobile?: boolean;
|
||||
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
||||
onPasteAsFile?: (file: File) => void;
|
||||
draftAppend?: string;
|
||||
onDraftAppendConsumed?: () => void;
|
||||
}
|
||||
|
||||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||
|
|
@ -67,30 +70,47 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
onRemovePendingFile,
|
||||
onFileUploadClick,
|
||||
uploading = false,
|
||||
selectedProviders = [],
|
||||
onProvidersChange,
|
||||
providerSelection,
|
||||
onProviderSelectionChange,
|
||||
isMobile = false,
|
||||
onTreeItemsDrop,
|
||||
onPasteAsFile,
|
||||
draftAppend,
|
||||
onDraftAppendConsumed,
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||||
const [treeDropOver, setTreeDropOver] = 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 _sttPrefsLoaded = useRef(false);
|
||||
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||
const [neutralizeActive, setNeutralizeActive] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (draftAppend) {
|
||||
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
|
||||
onDraftAppendConsumed?.();
|
||||
}
|
||||
}, [draftAppend, onDraftAppendConsumed]);
|
||||
|
||||
const promptBeforeVoiceRef = useRef('');
|
||||
const finalizedTextRef = useRef('');
|
||||
const currentInterimRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('workspace_stt_lang', voiceLanguage);
|
||||
}, [voiceLanguage]);
|
||||
if (_sttPrefsLoaded.current) return;
|
||||
_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(
|
||||
(text: string): string[] => {
|
||||
|
|
@ -116,12 +136,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
if (!trimmed || isProcessing) return;
|
||||
const inlineFileIds = _extractFileRefs(trimmed);
|
||||
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
|
||||
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds);
|
||||
const options = neutralizeActive ? { requireNeutralization: true } : undefined;
|
||||
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
|
||||
setPrompt('');
|
||||
setShowAutocomplete(false);
|
||||
setShowSourcePicker(false);
|
||||
setAttachedFileIds([]);
|
||||
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]);
|
||||
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
|
||||
|
||||
const _handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
|
|
@ -263,7 +284,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
}, [onPasteAsFile]);
|
||||
|
||||
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.dataTransfer.dropEffect = 'copy';
|
||||
setTreeDropOver(true);
|
||||
|
|
@ -273,11 +297,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||||
|
||||
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');
|
||||
if (treeItemsJson && onTreeItemsDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTreeDropOver(false);
|
||||
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
||||
onTreeItemsDrop(items);
|
||||
}
|
||||
|
|
@ -619,12 +654,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{onProvidersChange && (
|
||||
{onProviderSelectionChange && providerSelection && (
|
||||
<ProviderMultiSelect
|
||||
selectedProviders={selectedProviders}
|
||||
onChange={onProvidersChange}
|
||||
selection={providerSelection}
|
||||
onChange={onProviderSelectionChange}
|
||||
showLabel={false}
|
||||
excludeByDefault={['privatellm']}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -665,7 +699,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
{_STT_LANGUAGES.map(lang => (
|
||||
<div
|
||||
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={{
|
||||
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||||
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
|
||||
|
|
@ -681,6 +719,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
|||
)}
|
||||
</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 ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue