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;
|
language?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
privilege?: string;
|
privilege?: string;
|
||||||
|
registrationType?: 'personal' | 'company';
|
||||||
|
companyName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
|
|
@ -40,6 +42,8 @@ export interface RegisterRequest {
|
||||||
authenticationAuthority: string;
|
authenticationAuthority: string;
|
||||||
};
|
};
|
||||||
frontendUrl: string;
|
frontendUrl: string;
|
||||||
|
registrationType?: string;
|
||||||
|
companyName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordResetRequestResponse {
|
export interface PasswordResetRequestResponse {
|
||||||
|
|
@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise<RegisterR
|
||||||
privilege: registerData.privilege || 'user',
|
privilege: registerData.privilege || 'user',
|
||||||
authenticationAuthority: 'local'
|
authenticationAuthority: 'local'
|
||||||
},
|
},
|
||||||
frontendUrl: window.location.origin
|
frontendUrl: window.location.origin,
|
||||||
|
registrationType: registerData.registrationType,
|
||||||
|
companyName: registerData.companyName,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prepare headers with CSRF token if available
|
// Prepare headers with CSRF token if available
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,7 @@ export interface Automation2Task {
|
||||||
result?: Record<string, unknown>;
|
result?: Record<string, unknown>;
|
||||||
/** Workflow label (enriched by API) */
|
/** Workflow label (enriched by API) */
|
||||||
workflowLabel?: string;
|
workflowLabel?: string;
|
||||||
/** Unix timestamp ms (from _createdAt) */
|
/** Unix timestamp ms (from sysCreatedAt) */
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
/** Optional due date - configurable in future */
|
/** Optional due date - configurable in future */
|
||||||
dueAt?: number;
|
dueAt?: number;
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ export interface Automation {
|
||||||
nextExecution?: number;
|
nextExecution?: number;
|
||||||
executionLogs?: AutomationLog[];
|
executionLogs?: AutomationLog[];
|
||||||
allowedProviders?: string[];
|
allowedProviders?: string[];
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_updatedAt?: number;
|
_updatedAt?: number;
|
||||||
_createdByUserName?: string;
|
sysCreatedByUserName?: string;
|
||||||
mandateName?: string;
|
mandateName?: string;
|
||||||
featureInstanceName?: string;
|
featureInstanceName?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
|
|
@ -48,9 +48,9 @@ export interface AutomationTemplate {
|
||||||
label: TextMultilingual;
|
label: TextMultilingual;
|
||||||
overview?: TextMultilingual;
|
overview?: TextMultilingual;
|
||||||
template: string; // JSON string with {{KEY:...}} placeholders
|
template: string; // JSON string with {{KEY:...}} placeholders
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_createdByUserName?: string;
|
sysCreatedByUserName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow action definition from backend
|
// Workflow action definition from backend
|
||||||
|
|
@ -301,7 +301,7 @@ export async function fetchAutomationTemplateById(
|
||||||
*/
|
*/
|
||||||
export async function createAutomationTemplateApi(
|
export async function createAutomationTemplateApi(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
templateData: Omit<AutomationTemplate, 'id' | '_createdAt' | '_createdBy'>
|
templateData: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>
|
||||||
): Promise<AutomationTemplate> {
|
): Promise<AutomationTemplate> {
|
||||||
return await request({
|
return await request({
|
||||||
url: '/api/automation-templates',
|
url: '/api/automation-templates',
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,12 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER';
|
|
||||||
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT';
|
||||||
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM';
|
export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION';
|
||||||
|
|
||||||
export interface BillingBalance {
|
export interface BillingBalance {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
mandateName: string;
|
mandateName: string;
|
||||||
billingModel: BillingModel;
|
|
||||||
balance: number;
|
balance: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
warningThreshold: number;
|
warningThreshold: number;
|
||||||
|
|
@ -41,19 +39,21 @@ export interface BillingTransaction {
|
||||||
export interface BillingSettings {
|
export interface BillingSettings {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
billingModel: BillingModel;
|
|
||||||
defaultUserCredit: number;
|
|
||||||
warningThresholdPercent: number;
|
warningThresholdPercent: number;
|
||||||
notifyOnWarning: boolean;
|
notifyOnWarning: boolean;
|
||||||
notifyEmails: string[];
|
notifyEmails: string[];
|
||||||
|
autoRechargeEnabled?: boolean;
|
||||||
|
rechargeAmountCHF?: number;
|
||||||
|
rechargeMaxPerMonth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillingSettingsUpdate {
|
export interface BillingSettingsUpdate {
|
||||||
billingModel?: BillingModel;
|
|
||||||
defaultUserCredit?: number;
|
|
||||||
warningThresholdPercent?: number;
|
warningThresholdPercent?: number;
|
||||||
notifyOnWarning?: boolean;
|
notifyOnWarning?: boolean;
|
||||||
notifyEmails?: string[];
|
notifyEmails?: string[];
|
||||||
|
autoRechargeEnabled?: boolean;
|
||||||
|
rechargeAmountCHF?: number;
|
||||||
|
rechargeMaxPerMonth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageReport {
|
export interface UsageReport {
|
||||||
|
|
@ -69,7 +69,6 @@ export interface AccountSummary {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
accountType: string;
|
|
||||||
balance: number;
|
balance: number;
|
||||||
warningThreshold: number;
|
warningThreshold: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -305,10 +304,8 @@ export async function fetchUsersForMandateAdmin(
|
||||||
export interface MandateBalance {
|
export interface MandateBalance {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
mandateName: string;
|
mandateName: string;
|
||||||
billingModel: BillingModel;
|
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
defaultUserCredit: number;
|
|
||||||
warningThresholdPercent: number;
|
warningThresholdPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,18 +50,6 @@ export interface CoachingPersona {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoachingDocument {
|
|
||||||
id: string;
|
|
||||||
contextId: string;
|
|
||||||
fileName: string;
|
|
||||||
mimeType: string;
|
|
||||||
fileSize: number;
|
|
||||||
extractedText?: string;
|
|
||||||
summary?: string;
|
|
||||||
fileRef?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoachingBadge {
|
export interface CoachingBadge {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -110,8 +98,6 @@ export interface CoachingScore {
|
||||||
export interface CoachingUserProfile {
|
export interface CoachingUserProfile {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
preferredLanguage: string;
|
|
||||||
preferredVoice?: string;
|
|
||||||
dailyReminderTime?: string;
|
dailyReminderTime?: string;
|
||||||
dailyReminderEnabled: boolean;
|
dailyReminderEnabled: boolean;
|
||||||
emailSummaryEnabled: boolean;
|
emailSummaryEnabled: boolean;
|
||||||
|
|
@ -494,27 +480,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
|
||||||
return data.profile;
|
return data.profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Voice API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' });
|
|
||||||
return data.languages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise<any[]> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } });
|
|
||||||
return data.voices || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: {
|
|
||||||
text?: string; language?: string; voiceId?: string;
|
|
||||||
}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Persona API (Iteration 2)
|
// Persona API (Iteration 2)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -535,42 +500,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId:
|
||||||
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Document API (Iteration 2)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingDocument[]> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' });
|
|
||||||
return data.documents || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise<CoachingDocument> {
|
|
||||||
const baseURL = api.defaults.baseURL || '';
|
|
||||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const authToken = localStorage.getItem('authToken');
|
|
||||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
|
||||||
if (pathMatch) {
|
|
||||||
headers['X-Mandate-Id'] = pathMatch[1];
|
|
||||||
headers['X-Instance-Id'] = pathMatch[3];
|
|
||||||
}
|
|
||||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
|
||||||
addCSRFTokenToHeaders(headers);
|
|
||||||
|
|
||||||
const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' });
|
|
||||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
|
||||||
const data = await response.json();
|
|
||||||
return data.document;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise<void> {
|
|
||||||
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Badge API (Iteration 2)
|
// Badge API (Iteration 2)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export interface Prompt {
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
content: string;
|
content: string;
|
||||||
name: string;
|
name: string;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_hideDelete?: boolean;
|
_hideDelete?: boolean;
|
||||||
[key: string]: any; // Allow additional properties
|
[key: string]: any; // Allow additional properties
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ export interface RealEstateProject {
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
perimeter?: any;
|
perimeter?: any;
|
||||||
parzellen?: RealEstateParcel[];
|
parzellen?: RealEstateParcel[];
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,8 +38,8 @@ export interface RealEstateParcel {
|
||||||
plz?: string;
|
plz?: string;
|
||||||
perimeter?: any;
|
perimeter?: any;
|
||||||
bauzone?: string;
|
bauzone?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,21 @@
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
export interface StoreFeatureInstance {
|
||||||
|
instanceId: string;
|
||||||
|
mandateId: string;
|
||||||
|
mandateName: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreFeature {
|
export interface StoreFeature {
|
||||||
featureCode: string;
|
featureCode: string;
|
||||||
label: Record<string, string>;
|
label: Record<string, string>;
|
||||||
icon: string;
|
icon: string;
|
||||||
description: Record<string, string>;
|
description: Record<string, string>;
|
||||||
isActive: boolean;
|
instances: StoreFeatureInstance[];
|
||||||
canActivate: boolean;
|
canActivate: boolean;
|
||||||
instanceId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoreActivateResponse {
|
export interface StoreActivateResponse {
|
||||||
|
|
@ -31,17 +38,44 @@ export interface StoreDeactivateResponse {
|
||||||
deactivated: boolean;
|
deactivated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserMandate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionInfo {
|
||||||
|
plan: string | null;
|
||||||
|
status: string | null;
|
||||||
|
maxDataVolumeMB: number | null;
|
||||||
|
maxFeatureInstances: number | null;
|
||||||
|
budgetAiCHF: number | null;
|
||||||
|
currentFeatureInstances: number;
|
||||||
|
trialEndsAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
|
export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
|
||||||
const response = await api.get<StoreFeature[]>('/api/store/features');
|
const response = await api.get<StoreFeature[]>('/api/store/features');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activateStoreFeature(featureCode: string): Promise<StoreActivateResponse> {
|
export async function fetchUserMandates(): Promise<UserMandate[]> {
|
||||||
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode });
|
const response = await api.get<UserMandate[]>('/api/store/mandates');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivateStoreFeature(featureCode: string): Promise<StoreDeactivateResponse> {
|
export async function fetchSubscriptionInfo(mandateId?: string): Promise<SubscriptionInfo> {
|
||||||
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode });
|
const params = mandateId ? { mandateId } : {};
|
||||||
|
const response = await api.get<SubscriptionInfo>('/api/store/subscription-info', { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise<StoreActivateResponse> {
|
||||||
|
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode, mandateId });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise<StoreDeactivateResponse> {
|
||||||
|
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode, mandateId, instanceId });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export interface SubscriptionPlan {
|
||||||
autoRenew: boolean;
|
autoRenew: boolean;
|
||||||
maxUsers: number | null;
|
maxUsers: number | null;
|
||||||
maxFeatureInstances: number | null;
|
maxFeatureInstances: number | null;
|
||||||
|
maxDataVolumeMB?: number | null;
|
||||||
|
budgetAiCHF?: number;
|
||||||
trialDays: number | null;
|
trialDays: number | null;
|
||||||
successorPlanKey: string | null;
|
successorPlanKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@ export interface TrusteeOrganisation {
|
||||||
label: string;
|
label: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_modifiedBy?: string;
|
sysModifiedBy?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,10 +29,10 @@ export interface TrusteeRole {
|
||||||
id: string;
|
id: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_modifiedBy?: string;
|
sysModifiedBy?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,10 +43,10 @@ export interface TrusteeAccess {
|
||||||
userId: string;
|
userId: string;
|
||||||
contractId?: string | null;
|
contractId?: string | null;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_modifiedBy?: string;
|
sysModifiedBy?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,10 +56,10 @@ export interface TrusteeContract {
|
||||||
label: string;
|
label: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_modifiedBy?: string;
|
sysModifiedBy?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,10 +71,10 @@ export interface TrusteeDocument {
|
||||||
documentMimeType: string;
|
documentMimeType: string;
|
||||||
documentData?: any;
|
documentData?: any;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_modifiedBy?: string;
|
sysModifiedBy?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,10 +98,10 @@ export interface TrusteePosition {
|
||||||
costCenter?: string;
|
costCenter?: string;
|
||||||
bookingReference?: string;
|
bookingReference?: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
_createdBy?: string;
|
sysCreatedBy?: string;
|
||||||
_modifiedBy?: string;
|
sysModifiedBy?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,8 +696,8 @@ export interface TrusteePositionDocument {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
} from '../nodes/runtime/workflowStartSync';
|
} from '../nodes/runtime/workflowStartSync';
|
||||||
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
||||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||||
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
const LOG = '[Automation2]';
|
||||||
|
|
@ -55,6 +56,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
initialWorkflowId,
|
initialWorkflowId,
|
||||||
}) => {
|
}) => {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -140,8 +142,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||||
} else {
|
} else {
|
||||||
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
|
const label = await promptInput('Workflow-Name:', {
|
||||||
const created = await createWorkflow(request, instanceId, { label, graph, invocations });
|
title: 'Workflow speichern',
|
||||||
|
defaultValue: 'Neuer Workflow',
|
||||||
|
placeholder: 'Name des Workflows',
|
||||||
|
});
|
||||||
|
if (!label) {
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await createWorkflow(request, instanceId, {
|
||||||
|
label: label.trim() || 'Neuer Workflow',
|
||||||
|
graph,
|
||||||
|
invocations,
|
||||||
|
});
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
if (created.invocations?.length) setInvocations(created.invocations);
|
if (created.invocations?.length) setInvocations(created.invocations);
|
||||||
setWorkflows((prev) => [...prev, created]);
|
setWorkflows((prev) => [...prev, created]);
|
||||||
|
|
@ -152,7 +166,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]);
|
||||||
|
|
||||||
const handleLoad = useCallback(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
|
|
@ -436,7 +450,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<PromptDialog />
|
||||||
<WorkflowConfigurationModal
|
<WorkflowConfigurationModal
|
||||||
open={workflowSettingsOpen}
|
open={workflowSettingsOpen}
|
||||||
onClose={() => setWorkflowSettingsOpen(false)}
|
onClose={() => setWorkflowSettingsOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||||
import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
|
import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
|
||||||
import { Popup } from '../UiComponents/Popup';
|
import { Popup } from '../UiComponents/Popup';
|
||||||
import { ActionsPanel } from '../ActionsPanel';
|
import { ActionsPanel } from '../ActionsPanel';
|
||||||
import { ProviderMultiSelect } from '../ProviderSelector';
|
import { ProviderMultiSelect, _defaultProviderSelection, _migrateFromLegacy, _toBackendProviders } from '../ProviderSelector';
|
||||||
|
import type { ProviderSelection } from '../ProviderSelector';
|
||||||
|
import { useBilling } from '../../hooks/useBilling';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { useWorkflowActions } from '../../hooks/useAutomations';
|
import { useWorkflowActions } from '../../hooks/useAutomations';
|
||||||
|
|
@ -374,7 +376,8 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [schedule, setSchedule] = useState('0 22 * * *');
|
const [schedule, setSchedule] = useState('0 22 * * *');
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const [allowedProviders, setAllowedProviders] = useState<string[]>([]);
|
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
|
||||||
|
const { allowedProviders: billingProviders } = useBilling();
|
||||||
|
|
||||||
// Template multilingual fields
|
// Template multilingual fields
|
||||||
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
|
const [labelMulti, setLabelMulti] = useState<LocalTextMultilingual>({ en: '', de: '' });
|
||||||
|
|
@ -537,7 +540,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
||||||
setLabel(def.label || '');
|
setLabel(def.label || '');
|
||||||
setSchedule(def.schedule || '0 22 * * *');
|
setSchedule(def.schedule || '0 22 * * *');
|
||||||
setActive(def.active ?? false);
|
setActive(def.active ?? false);
|
||||||
setAllowedProviders(def.allowedProviders || []);
|
setProviderSelection(_migrateFromLegacy(def.allowedProviders || []));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract template JSON
|
// Extract template JSON
|
||||||
|
|
@ -693,7 +696,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
||||||
active,
|
active,
|
||||||
template: templateJson,
|
template: templateJson,
|
||||||
placeholders,
|
placeholders,
|
||||||
allowedProviders
|
allowedProviders: _toBackendProviders(providerSelection, billingProviders),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -709,7 +712,7 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
|
}, [label, schedule, active, providerSelection, billingProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]);
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
const editorTitle = title || (mode === 'template'
|
const editorTitle = title || (mode === 'template'
|
||||||
|
|
@ -864,12 +867,12 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
|
||||||
{/* Allowed AI Providers */}
|
{/* Allowed AI Providers */}
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<ProviderMultiSelect
|
<ProviderMultiSelect
|
||||||
selectedProviders={allowedProviders}
|
selection={providerSelection}
|
||||||
onChange={setAllowedProviders}
|
onChange={setProviderSelection}
|
||||||
label="Erlaubte AI-Provider"
|
label="Erlaubte AI-Provider"
|
||||||
/>
|
/>
|
||||||
<p className={styles.formHint}>
|
<p className={styles.formHint}>
|
||||||
Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt.
|
Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,25 @@
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--color-text-secondary, #999);
|
color: var(--color-text-secondary, #999);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopeIcons {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightZone {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightZone .actions {
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rootActions {
|
.rootActions {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import styles from './FolderTree.module.css';
|
import styles from './FolderTree.module.css';
|
||||||
|
|
||||||
/* ── Public types ──────────────────────────────────────────────────────── */
|
/* ── Public types ──────────────────────────────────────────────────────── */
|
||||||
|
|
@ -30,6 +31,8 @@ export interface FileNode {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
|
scope?: string;
|
||||||
|
neutralize?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeItem {
|
export interface TreeItem {
|
||||||
|
|
@ -62,6 +65,8 @@ export interface FolderTreeProps {
|
||||||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||||
|
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||||
|
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
||||||
|
|
@ -146,6 +151,22 @@ function _fileIcon(mime?: string): string {
|
||||||
|
|
||||||
/* ── Selection context threaded through the tree ──────────────────────── */
|
/* ── Selection context threaded through the tree ──────────────────────── */
|
||||||
|
|
||||||
|
const _SCOPE_ICONS: Record<string, string> = {
|
||||||
|
personal: '\uD83D\uDC64',
|
||||||
|
featureInstance: '\uD83D\uDC65',
|
||||||
|
mandate: '\uD83C\uDFE2',
|
||||||
|
global: '\uD83C\uDF10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
|
||||||
|
|
||||||
|
const _SCOPE_LABELS: Record<string, string> = {
|
||||||
|
personal: 'Persönlich',
|
||||||
|
featureInstance: 'Instanz',
|
||||||
|
mandate: 'Mandant',
|
||||||
|
global: 'Global',
|
||||||
|
};
|
||||||
|
|
||||||
interface SelectionCtx {
|
interface SelectionCtx {
|
||||||
selectedItemIds: Set<string>;
|
selectedItemIds: Set<string>;
|
||||||
selectedFileIds: string[];
|
selectedFileIds: string[];
|
||||||
|
|
@ -156,6 +177,8 @@ interface SelectionCtx {
|
||||||
onDeleteFile?: (fileId: string) => Promise<void>;
|
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
|
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||||
|
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||||||
|
|
@ -227,39 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.folderName}>{file.fileName}</span>
|
<span className={styles.folderName}>{file.fileName}</span>
|
||||||
)}
|
)}
|
||||||
{!renaming && file.fileSize != null && (
|
|
||||||
<span className={styles.fileSize}>
|
|
||||||
{(file.fileSize / 1024).toFixed(0)}K
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!renaming && (
|
{!renaming && (
|
||||||
<span className={styles.actions}>
|
<span className={styles.rightZone}>
|
||||||
{sel.onRenameFile && !multiSelected && (
|
<span className={styles.actions}>
|
||||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
{sel.onRenameFile && !multiSelected && (
|
||||||
<FaPen />
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||||
</button>
|
<FaPen />
|
||||||
)}
|
|
||||||
{multiSelected && isSelected ? (
|
|
||||||
<>
|
|
||||||
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
|
||||||
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
|
||||||
<FaTrash />
|
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
|
||||||
<FaTrash />
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)}
|
||||||
|
{multiSelected && isSelected ? (
|
||||||
|
<>
|
||||||
|
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
||||||
|
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
||||||
|
<FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{file.fileSize != null && (
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{(file.fileSize / 1024).toFixed(0)}K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.scope != null && (
|
||||||
|
<span className={styles.scopeIcons}>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!sel.onScopeChange) return;
|
||||||
|
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
|
||||||
|
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
||||||
|
sel.onScopeChange(file.id, next);
|
||||||
|
}}
|
||||||
|
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
>
|
||||||
|
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
|
||||||
|
}}
|
||||||
|
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
|
||||||
|
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -277,6 +331,7 @@ interface TreeNodeProps {
|
||||||
showFiles: boolean;
|
showFiles: boolean;
|
||||||
filesByFolder: Map<string, FileNode[]>;
|
filesByFolder: Map<string, FileNode[]>;
|
||||||
sel: SelectionCtx;
|
sel: SelectionCtx;
|
||||||
|
promptFolderName: (message: string) => Promise<string | null>;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
||||||
|
|
@ -291,6 +346,7 @@ interface TreeNodeProps {
|
||||||
|
|
||||||
function _TreeNode({
|
function _TreeNode({
|
||||||
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
||||||
|
promptFolderName,
|
||||||
onToggle, onSelect,
|
onToggle, onSelect,
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onDownloadFolder,
|
onDownloadFolder,
|
||||||
|
|
@ -321,12 +377,12 @@ function _TreeNode({
|
||||||
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!onCreateFolder) return;
|
if (!onCreateFolder) return;
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) {
|
if (name?.trim()) {
|
||||||
await onCreateFolder(name.trim(), node.id);
|
await onCreateFolder(name.trim(), node.id);
|
||||||
if (!expandedIds.has(node.id)) onToggle(node.id);
|
if (!expandedIds.has(node.id)) onToggle(node.id);
|
||||||
}
|
}
|
||||||
}, [onCreateFolder, node.id, expandedIds, onToggle]);
|
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]);
|
||||||
|
|
||||||
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -488,6 +544,7 @@ function _TreeNode({
|
||||||
showFiles={showFiles}
|
showFiles={showFiles}
|
||||||
filesByFolder={filesByFolder}
|
filesByFolder={filesByFolder}
|
||||||
sel={sel}
|
sel={sel}
|
||||||
|
promptFolderName={promptFolderName}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onCreateFolder={onCreateFolder}
|
onCreateFolder={onCreateFolder}
|
||||||
|
|
@ -517,11 +574,13 @@ export default function FolderTree({
|
||||||
expandedIds: externalExpandedIds, onToggleExpand,
|
expandedIds: externalExpandedIds, onToggleExpand,
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||||
|
onScopeChange, onNeutralizeToggle,
|
||||||
}: FolderTreeProps) {
|
}: FolderTreeProps) {
|
||||||
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [rootDropOver, setRootDropOver] = useState(false);
|
const [rootDropOver, setRootDropOver] = useState(false);
|
||||||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const lastClickedIdRef = useRef<string | null>(null);
|
const lastClickedIdRef = useRef<string | null>(null);
|
||||||
|
const { prompt: promptFolderName, PromptDialog } = usePrompt();
|
||||||
|
|
||||||
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
||||||
|
|
||||||
|
|
@ -634,8 +693,10 @@ export default function FolderTree({
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
onDeleteFiles,
|
onDeleteFiles,
|
||||||
onDeleteFolders,
|
onDeleteFolders,
|
||||||
|
onScopeChange,
|
||||||
|
onNeutralizeToggle,
|
||||||
};
|
};
|
||||||
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]);
|
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
|
||||||
|
|
||||||
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -699,7 +760,7 @@ export default function FolderTree({
|
||||||
className={styles.actionBtn}
|
className={styles.actionBtn}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
||||||
}}
|
}}
|
||||||
title="Neuer Ordner"
|
title="Neuer Ordner"
|
||||||
|
|
@ -720,6 +781,7 @@ export default function FolderTree({
|
||||||
showFiles={showFiles}
|
showFiles={showFiles}
|
||||||
filesByFolder={filesByFolder}
|
filesByFolder={filesByFolder}
|
||||||
sel={sel}
|
sel={sel}
|
||||||
|
promptFolderName={promptFolderName}
|
||||||
onToggle={_handleToggle}
|
onToggle={_handleToggle}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onCreateFolder={onCreateFolder}
|
onCreateFolder={onCreateFolder}
|
||||||
|
|
@ -736,6 +798,7 @@ export default function FolderTree({
|
||||||
<_FileItem key={file.id} file={file} sel={sel} />
|
<_FileItem key={file.id} file={file} sel={sel} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,27 @@
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rename button (inline, hover-visible via TreeNavigation nodeActions) */
|
||||||
|
.renameButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.renameButton:hover {
|
||||||
|
color: var(--primary-color, #2563eb);
|
||||||
|
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark Theme */
|
/* Dark Theme */
|
||||||
:global(.dark-theme) .separator {
|
:global(.dark-theme) .separator {
|
||||||
background: var(--border-dark, #333);
|
background: var(--border-dark, #333);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
* - Users, Mandates, Roles, ...
|
* - Users, Mandates, Roles, ...
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import { useNavigation } from '../../hooks/useNavigation';
|
import { useNavigation } from '../../hooks/useNavigation';
|
||||||
import type {
|
import type {
|
||||||
DynamicBlock,
|
DynamicBlock,
|
||||||
|
|
@ -31,8 +31,11 @@ import type {
|
||||||
FeatureView
|
FeatureView
|
||||||
} from '../../hooks/useNavigation';
|
} from '../../hooks/useNavigation';
|
||||||
import { getPageIcon } from '../../config/pageRegistry';
|
import { getPageIcon } from '../../config/pageRegistry';
|
||||||
import { FaSpinner } from 'react-icons/fa';
|
import { FaSpinner, FaPen } from 'react-icons/fa';
|
||||||
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import api from '../../api';
|
||||||
import styles from './MandateNavigation.module.css';
|
import styles from './MandateNavigation.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -84,16 +87,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
|
||||||
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
* Convert a FeatureInstance to TreeNodeItem (with feature icon)
|
||||||
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
|
* Instance node gets path to first view so clicking the instance name navigates to dashboard.
|
||||||
* Shows the feature icon next to the instance name for visual distinction.
|
* Shows the feature icon next to the instance name for visual distinction.
|
||||||
|
* If user is instance admin, a rename icon appears on hover.
|
||||||
*/
|
*/
|
||||||
function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem {
|
function featureInstanceToTreeNode(
|
||||||
|
instance: FeatureInstance,
|
||||||
|
featureUiComponent: string,
|
||||||
|
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||||
|
): TreeNodeItem {
|
||||||
const children = instance.views.map(featureViewToTreeNode);
|
const children = instance.views.map(featureViewToTreeNode);
|
||||||
|
const renameAction = instance.isAdmin && onRename ? (
|
||||||
|
<button
|
||||||
|
className={styles.renameButton}
|
||||||
|
title="Umbenennen"
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRename(instance.id, instance.uiLabel); }}
|
||||||
|
>
|
||||||
|
<FaPen size={10} />
|
||||||
|
</button>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
label: instance.uiLabel,
|
label: instance.uiLabel,
|
||||||
icon: getPageIcon(featureUiComponent), // Use feature icon for instance
|
icon: getPageIcon(featureUiComponent),
|
||||||
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
path: instance.views.length > 0 ? instance.views[0].uiPath : undefined,
|
||||||
children,
|
children,
|
||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
|
actions: renameAction,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,16 +125,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent
|
||||||
* Before: Mandate → Feature → Instance → Views
|
* Before: Mandate → Feature → Instance → Views
|
||||||
* Now: Mandate → Instance (with feature icon) → Views
|
* Now: Mandate → Instance (with feature icon) → Views
|
||||||
*/
|
*/
|
||||||
function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null {
|
function navigationMandateToTreeNode(
|
||||||
|
mandate: NavigationMandate,
|
||||||
|
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||||
|
): TreeNodeItem | null {
|
||||||
if (mandate.features.length === 0) {
|
if (mandate.features.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten: collect all instances from all features directly under mandate
|
|
||||||
const instanceNodes: TreeNodeItem[] = [];
|
const instanceNodes: TreeNodeItem[] = [];
|
||||||
for (const feature of mandate.features) {
|
for (const feature of mandate.features) {
|
||||||
for (const instance of feature.instances) {
|
for (const instance of feature.instances) {
|
||||||
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent));
|
instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,9 +155,12 @@ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem |
|
||||||
/**
|
/**
|
||||||
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
|
* Convert a DynamicBlock to array of TreeNodeItems (mandate nodes)
|
||||||
*/
|
*/
|
||||||
function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] {
|
function dynamicBlockToTreeNodes(
|
||||||
|
block: DynamicBlock,
|
||||||
|
onRename?: (instanceId: string, currentLabel: string) => void,
|
||||||
|
): TreeNodeItem[] {
|
||||||
return block.mandates
|
return block.mandates
|
||||||
.map(navigationMandateToTreeNode)
|
.map((m) => navigationMandateToTreeNode(m, onRename))
|
||||||
.filter((node): node is TreeNodeItem => node !== null);
|
.filter((node): node is TreeNodeItem => node !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,18 +193,24 @@ const EmptyState: React.FC = () => (
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const MandateNavigation: React.FC = () => {
|
export const MandateNavigation: React.FC = () => {
|
||||||
// Fetch navigation from new API (blocks structure, already filtered by permissions)
|
const { blocks, loading, refresh } = useNavigation('de');
|
||||||
const { blocks, loading } = useNavigation('de');
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
|
const { showWarning } = useToast();
|
||||||
// Build navigation items from blocks
|
|
||||||
// Groups static items into collapsible containers:
|
const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
|
||||||
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
|
const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
|
||||||
// - "Administration": admin items, possibly with subgroups
|
if (!newLabel || newLabel.trim() === currentLabel) return;
|
||||||
// - Dynamic block (mandates) renders between them
|
try {
|
||||||
|
await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() });
|
||||||
|
refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message));
|
||||||
|
}
|
||||||
|
}, [refresh, prompt, showWarning]);
|
||||||
|
|
||||||
const navigationItems: TreeItem[] = useMemo(() => {
|
const navigationItems: TreeItem[] = useMemo(() => {
|
||||||
const items: TreeItem[] = [];
|
const items: TreeItem[] = [];
|
||||||
|
|
||||||
// Collect static items by category
|
|
||||||
const meineSichtItems: NavigationItem[] = [];
|
const meineSichtItems: NavigationItem[] = [];
|
||||||
let adminItems: NavigationItem[] = [];
|
let adminItems: NavigationItem[] = [];
|
||||||
let adminSubgroups: NavSubgroup[] = [];
|
let adminSubgroups: NavSubgroup[] = [];
|
||||||
|
|
@ -199,15 +229,13 @@ export const MandateNavigation: React.FC = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Meine Sicht" - collapsible container for user-facing pages
|
|
||||||
if (meineSichtItems.length > 0) {
|
if (meineSichtItems.length > 0) {
|
||||||
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
|
items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic block: mandates with feature instances
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
if (block.type === 'dynamic') {
|
if (block.type === 'dynamic') {
|
||||||
const mandateNodes = dynamicBlockToTreeNodes(block);
|
const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename);
|
||||||
if (mandateNodes.length > 0) {
|
if (mandateNodes.length > 0) {
|
||||||
if (items.length > 0) items.push({ type: 'separator' });
|
if (items.length > 0) items.push({ type: 'separator' });
|
||||||
items.push(...mandateNodes);
|
items.push(...mandateNodes);
|
||||||
|
|
@ -215,7 +243,6 @@ export const MandateNavigation: React.FC = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Administration" - collapsible container for admin pages (with subgroup support)
|
|
||||||
if (adminSubgroups.length > 0) {
|
if (adminSubgroups.length > 0) {
|
||||||
if (items.length > 0) items.push({ type: 'separator' });
|
if (items.length > 0) items.push({ type: 'separator' });
|
||||||
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
|
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
|
||||||
|
|
@ -236,7 +263,7 @@ export const MandateNavigation: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [blocks]);
|
}, [blocks, _handleRename]);
|
||||||
|
|
||||||
// Check if user has any navigation (static or dynamic)
|
// Check if user has any navigation (static or dynamic)
|
||||||
const hasNavigation = blocks.length > 0;
|
const hasNavigation = blocks.length > 0;
|
||||||
|
|
@ -260,6 +287,7 @@ export const MandateNavigation: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,22 @@
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================ */
|
||||||
|
/* NODE ACTIONS (hover-reveal inline icons) */
|
||||||
|
/* ============================================ */
|
||||||
|
|
||||||
|
.nodeActions {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeNode:hover .nodeActions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
/* DARK THEME */
|
/* DARK THEME */
|
||||||
/* ============================================ */
|
/* ============================================ */
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ export interface TreeNodeItem {
|
||||||
level?: number;
|
level?: number;
|
||||||
/** Data attribute for testing/identification */
|
/** Data attribute for testing/identification */
|
||||||
dataId?: string;
|
dataId?: string;
|
||||||
|
/** Inline action element rendered at the end of the row (e.g. rename icon) */
|
||||||
|
actions?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeSectionItem {
|
export interface TreeSectionItem {
|
||||||
|
|
@ -219,6 +221,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
{node.badge}
|
{node.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{node.actions && (
|
||||||
|
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{node.actions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
import { NotificationBell } from '../NotificationBell';
|
import { NotificationBell } from '../NotificationBell';
|
||||||
|
import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant';
|
||||||
import styles from './UserSection.module.css';
|
import styles from './UserSection.module.css';
|
||||||
|
|
||||||
export const UserSection: React.FC = () => {
|
export const UserSection: React.FC = () => {
|
||||||
|
|
@ -16,6 +17,7 @@ export const UserSection: React.FC = () => {
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const [showLegalModal, setShowLegalModal] = useState(false);
|
const [showLegalModal, setShowLegalModal] = useState(false);
|
||||||
|
const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden());
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|
@ -41,6 +43,13 @@ export const UserSection: React.FC = () => {
|
||||||
setShowLegalModal(true);
|
setShowLegalModal(true);
|
||||||
setShowMenu(false);
|
setShowMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOnboarding = () => {
|
||||||
|
_showOnboarding();
|
||||||
|
setOnboardingHidden(false);
|
||||||
|
navigate('/', { state: { showOnboarding: Date.now() } });
|
||||||
|
setShowMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -61,7 +70,7 @@ export const UserSection: React.FC = () => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.userButton}
|
className={styles.userButton}
|
||||||
onClick={() => setShowMenu(!showMenu)}
|
onClick={() => { setShowMenu(!showMenu); setOnboardingHidden(_isOnboardingHidden()); }}
|
||||||
aria-expanded={showMenu}
|
aria-expanded={showMenu}
|
||||||
>
|
>
|
||||||
<div className={styles.avatar}>
|
<div className={styles.avatar}>
|
||||||
|
|
@ -94,6 +103,16 @@ export const UserSection: React.FC = () => {
|
||||||
Einstellungen
|
Einstellungen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onboardingHidden && (
|
||||||
|
<button
|
||||||
|
className={styles.menuItem}
|
||||||
|
onClick={handleOnboarding}
|
||||||
|
>
|
||||||
|
<span className={styles.menuIcon}>{'\uD83E\uDDED'}</span>
|
||||||
|
Onboarding-Assistent
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.menuItem}
|
className={styles.menuItem}
|
||||||
onClick={handleLegal}
|
onClick={handleLegal}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ const typeIcons: Record<string, React.ReactNode> = {
|
||||||
mention: <FaExclamationTriangle />
|
mention: <FaExclamationTriangle />
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format timestamp to relative time
|
// Format timestamp to relative time (Unix seconds)
|
||||||
function formatRelativeTime(timestamp: number): string {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
const diff = now - timestamp;
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
|
@ -29,6 +32,9 @@ function formatRelativeTime(timestamp: number): string {
|
||||||
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
||||||
|
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
return date.toLocaleDateString('de-DE');
|
return date.toLocaleDateString('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border: 1px solid var(--border-color, #3a3a3a);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--surface-color, #2d2d2d);
|
background: var(--surface-color, #ffffff);
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #666666);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerButton:hover:not(:disabled) {
|
.triggerButton:hover:not(:disabled) {
|
||||||
background: var(--bg-secondary, #3a3a3a);
|
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||||
color: var(--text-primary, #fff);
|
color: var(--text-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.triggerButton:disabled {
|
.triggerButton:disabled {
|
||||||
|
|
@ -83,20 +83,20 @@
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--surface-color, #2d2d2d);
|
background: var(--surface-color, #ffffff);
|
||||||
border: 1px solid var(--border-color, #3a3a3a);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdownHeader {
|
.dropdownHeader {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #666666);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
border-bottom: 1px solid var(--border-color, #3a3a3a);
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectActions {
|
.selectActions {
|
||||||
|
|
@ -108,18 +108,18 @@
|
||||||
.actionButton {
|
.actionButton {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border: 1px solid var(--border-color, #3a3a3a);
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-secondary, #252525);
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #666666);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButton:hover:not(:disabled) {
|
.actionButton:hover:not(:disabled) {
|
||||||
background: var(--bg-hover, #3a3a3a);
|
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||||
color: var(--text-primary, #fff);
|
color: var(--text-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButton.active {
|
.actionButton.active {
|
||||||
|
|
@ -138,7 +138,7 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: var(--bg-secondary, #252525);
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -151,12 +151,13 @@
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease;
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxItem:hover {
|
.checkboxItem:hover {
|
||||||
background: var(--bg-hover, #3a3a3a);
|
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxItem.disabled {
|
.checkboxItem.disabled {
|
||||||
|
|
@ -177,12 +178,12 @@
|
||||||
|
|
||||||
.providerName {
|
.providerName {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-tertiary, #666);
|
color: var(--text-tertiary, #888888);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
@ -192,10 +193,24 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #666666);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark theme: list hover stays a light lift, not a black wash */
|
||||||
|
:global(.dark-theme) .checkboxItem:hover {
|
||||||
|
background: var(--hover-bg, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--text-primary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .checkboxItem {
|
||||||
|
color: var(--text-primary, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .dropdownContent {
|
||||||
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
PROVIDER BADGES
|
PROVIDER BADGES
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,80 @@
|
||||||
/**
|
/**
|
||||||
* ProviderSelector Component
|
* ProviderSelector Component
|
||||||
*
|
*
|
||||||
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
|
* Wiederverwendbare Komponente zur Auswahl von AICore-Providern.
|
||||||
* Kann im AI Workspace und Automation Editor verwendet werden.
|
* Kann im AI Workspace und Automation Editor verwendet werden.
|
||||||
*
|
*
|
||||||
* Features:
|
* Selektionsmodell:
|
||||||
* - Dropdown für Einzelauswahl
|
* ProviderSelection { include: string[], exclude: string[] }
|
||||||
* - Checkbox-Liste für Mehrfachauswahl
|
* - include(["ALL"]), exclude([]) → alle verfügbaren Provider (dynamisch)
|
||||||
* - Lädt verfügbare Provider aus dem Billing-System
|
* - include(["ALL"]), exclude(["private"]) → alle ausser "private" (dynamisch)
|
||||||
|
* - include(["anthropic"]), exclude([]) → nur Anthropic
|
||||||
|
* - include([]), exclude([]) → keiner ausgewählt
|
||||||
|
*
|
||||||
|
* resolveProviders(selection, allowedProviders) liefert die konkrete Liste.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||||
import { useBilling } from '../../hooks/useBilling';
|
import { useBilling } from '../../hooks/useBilling';
|
||||||
import styles from './ProviderSelector.module.css';
|
import styles from './ProviderSelector.module.css';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES & HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const PROVIDER_ALL = 'ALL';
|
||||||
|
|
||||||
|
export interface ProviderSelection {
|
||||||
|
include: string[];
|
||||||
|
exclude: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _defaultProviderSelection(): ProviderSelection {
|
||||||
|
return { include: [PROVIDER_ALL], exclude: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _resolveProviders(
|
||||||
|
selection: ProviderSelection,
|
||||||
|
allowedProviders: string[],
|
||||||
|
): string[] {
|
||||||
|
if (selection.include.includes(PROVIDER_ALL)) {
|
||||||
|
return allowedProviders.filter((p) => !selection.exclude.includes(p));
|
||||||
|
}
|
||||||
|
return selection.include.filter((p) => allowedProviders.includes(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _isAllSelected(selection: ProviderSelection): boolean {
|
||||||
|
return selection.include.includes(PROVIDER_ALL) && selection.exclude.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _isNoneSelected(
|
||||||
|
selection: ProviderSelection,
|
||||||
|
allowedProviders: string[],
|
||||||
|
): boolean {
|
||||||
|
return _resolveProviders(selection, allowedProviders).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy string[] (old model) to ProviderSelection.
|
||||||
|
* [] → ALL, [...ids] → include those ids.
|
||||||
|
*/
|
||||||
|
export function _migrateFromLegacy(providers: string[]): ProviderSelection {
|
||||||
|
if (providers.length === 0) return _defaultProviderSelection();
|
||||||
|
return { include: providers, exclude: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ProviderSelection to flat list for backend API calls.
|
||||||
|
* Returns [] when ALL are selected (= no restriction / legacy behaviour).
|
||||||
|
*/
|
||||||
|
export function _toBackendProviders(
|
||||||
|
selection: ProviderSelection,
|
||||||
|
allowedProviders: string[],
|
||||||
|
): string[] {
|
||||||
|
if (_isAllSelected(selection)) return [];
|
||||||
|
return _resolveProviders(selection, allowedProviders);
|
||||||
|
}
|
||||||
|
|
||||||
// Provider display names
|
// Provider display names
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
anthropic: 'Anthropic (Claude)',
|
anthropic: 'Anthropic (Claude)',
|
||||||
|
|
@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
internal: 'Internal',
|
internal: 'Internal',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Provider icons (emojis for simplicity)
|
|
||||||
const PROVIDER_ICONS: Record<string, string> = {
|
const PROVIDER_ICONS: Record<string, string> = {
|
||||||
anthropic: '🤖',
|
anthropic: '🤖',
|
||||||
openai: '💬',
|
openai: '💬',
|
||||||
|
|
@ -58,20 +118,20 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
}) => {
|
}) => {
|
||||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowedProviders.length === 0 && !loading) {
|
if (allowedProviders.length === 0 && !loading) {
|
||||||
loadAllowedProviders();
|
loadAllowedProviders();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const providerOptions = useMemo(() => {
|
const providerOptions = useMemo(() => {
|
||||||
return allowedProviders.map((provider) => ({
|
return allowedProviders.map((provider) => ({
|
||||||
value: provider,
|
value: provider,
|
||||||
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
|
label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`,
|
||||||
}));
|
}));
|
||||||
}, [allowedProviders]);
|
}, [allowedProviders]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.providerSelect} ${className || ''}`}>
|
<div className={`${styles.providerSelect} ${className || ''}`}>
|
||||||
{showLabel && <label className={styles.label}>{label}</label>}
|
{showLabel && <label className={styles.label}>{label}</label>}
|
||||||
|
|
@ -93,12 +153,12 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MULTI SELECT COMPONENT (Checkbox List)
|
// MULTI SELECT COMPONENT (Checkbox List) — include / exclude model
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface ProviderMultiSelectProps {
|
interface ProviderMultiSelectProps {
|
||||||
selectedProviders: string[];
|
selection: ProviderSelection;
|
||||||
onChange: (providers: string[]) => void;
|
onChange: (selection: ProviderSelection) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
@ -108,7 +168,7 @@ interface ProviderMultiSelectProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
selectedProviders,
|
selection,
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className,
|
className,
|
||||||
|
|
@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
|
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowedProviders.length === 0 && !loading) {
|
if (allowedProviders.length === 0 && !loading) {
|
||||||
loadAllowedProviders();
|
loadAllowedProviders();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply default exclusions when providers first load
|
// Apply default exclusions once when providers first load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!initialExcludeApplied &&
|
!initialExcludeApplied &&
|
||||||
allowedProviders.length > 0 &&
|
allowedProviders.length > 0 &&
|
||||||
excludeByDefault.length > 0 &&
|
excludeByDefault.length > 0 &&
|
||||||
selectedProviders.length === 0
|
_isAllSelected(selection)
|
||||||
) {
|
) {
|
||||||
const initialSelection = allowedProviders.filter(
|
onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] });
|
||||||
(p) => !excludeByDefault.includes(p)
|
|
||||||
);
|
|
||||||
// Only apply if there's actually something to exclude
|
|
||||||
if (initialSelection.length < allowedProviders.length) {
|
|
||||||
onChange(initialSelection);
|
|
||||||
}
|
|
||||||
setInitialExcludeApplied(true);
|
setInitialExcludeApplied(true);
|
||||||
}
|
}
|
||||||
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]);
|
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]);
|
||||||
|
|
||||||
// Click outside handler
|
const _handleClickOutside = useCallback((event: MouseEvent) => {
|
||||||
const handleClickOutside = useCallback((event: MouseEvent) => {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', _handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
||||||
}
|
}
|
||||||
}, [isExpanded, handleClickOutside]);
|
}, [isExpanded, _handleClickOutside]);
|
||||||
|
|
||||||
// Effective selection: empty array = all providers active (no restriction)
|
const effectiveSelection = useMemo(
|
||||||
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
|
() => _resolveProviders(selection, allowedProviders),
|
||||||
|
[selection, allowedProviders],
|
||||||
// "Alle" is active when no restriction is set (empty array) OR all explicitly selected
|
);
|
||||||
const isAllSelected = selectedProviders.length === 0 ||
|
|
||||||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
|
const allSelected = _isAllSelected(selection);
|
||||||
|
const noneSelected = effectiveSelection.length === 0;
|
||||||
const handleToggle = (provider: string) => {
|
|
||||||
if (selectedProviders.length === 0) {
|
const _handleToggle = (provider: string) => {
|
||||||
// Currently "all active" (no restriction) -> make explicit: all except the toggled one
|
const isChecked = effectiveSelection.includes(provider);
|
||||||
onChange(allowedProviders.filter((p) => p !== provider));
|
|
||||||
} else if (selectedProviders.includes(provider)) {
|
if (selection.include.includes(PROVIDER_ALL)) {
|
||||||
// Deactivate: remove from selection
|
// Currently ALL-based: toggle modifies exclude list
|
||||||
const remaining = selectedProviders.filter((p) => p !== provider);
|
if (isChecked) {
|
||||||
// If removing leaves all others selected, reset to [] (= all, no restriction)
|
onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] });
|
||||||
if (remaining.length === allowedProviders.length) {
|
|
||||||
onChange([]);
|
|
||||||
} else {
|
} else {
|
||||||
onChange(remaining);
|
const nextExclude = selection.exclude.filter((p) => p !== provider);
|
||||||
|
onChange({ include: [PROVIDER_ALL], exclude: nextExclude });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Activate: add to selection
|
// Explicit include list
|
||||||
const updated = [...selectedProviders, provider];
|
if (isChecked) {
|
||||||
// If all are now selected, reset to [] (= all, no restriction)
|
onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] });
|
||||||
if (updated.length === allowedProviders.length) {
|
|
||||||
onChange([]);
|
|
||||||
} else {
|
} else {
|
||||||
onChange(updated);
|
const nextInclude = [...selection.include, provider];
|
||||||
|
if (nextInclude.length === allowedProviders.length) {
|
||||||
|
onChange({ include: [PROVIDER_ALL], exclude: [] });
|
||||||
|
} else {
|
||||||
|
onChange({ include: nextInclude, exclude: [] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const _handleSelectAll = () => {
|
||||||
onChange([]); // Empty = all active, no restriction
|
onChange({ include: [PROVIDER_ALL], exclude: [] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Summary icon for button
|
|
||||||
const summaryIcon = useMemo(() => {
|
const summaryIcon = useMemo(() => {
|
||||||
|
if (noneSelected) return '⊘';
|
||||||
if (effectiveSelection.length === 1) {
|
if (effectiveSelection.length === 1) {
|
||||||
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
|
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
|
||||||
}
|
}
|
||||||
return '🤖';
|
return '⚡';
|
||||||
}, [effectiveSelection]);
|
}, [effectiveSelection, noneSelected]);
|
||||||
|
|
||||||
|
const summaryHint = useMemo(() => {
|
||||||
|
if (noneSelected) return 'Kein Provider ausgewählt';
|
||||||
|
if (allSelected) return 'Alle Provider aktiv (dynamisch)';
|
||||||
|
if (selection.include.includes(PROVIDER_ALL)) {
|
||||||
|
return `Alle ausser ${selection.exclude.length} Provider`;
|
||||||
|
}
|
||||||
|
return `${effectiveSelection.length} von ${allowedProviders.length} Provider`;
|
||||||
|
}, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
className={`${styles.providerMultiSelect} ${className || ''} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||||
>
|
>
|
||||||
{/* Trigger Button - styled like iconButton */}
|
<button
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.triggerButton}
|
className={styles.triggerButton}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
|
@ -220,36 +283,35 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
>
|
>
|
||||||
<span className={styles.buttonIcon}>{summaryIcon}</span>
|
<span className={styles.buttonIcon}>{summaryIcon}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Content */}
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className={styles.dropdownContent}>
|
<div className={styles.dropdownContent}>
|
||||||
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
|
{showLabel && <div className={styles.dropdownHeader}>{label}</div>}
|
||||||
|
|
||||||
<div className={styles.selectActions}>
|
<div className={styles.selectActions}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSelectAll}
|
onClick={_handleSelectAll}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`${styles.actionButton} ${isAllSelected ? styles.active : ''}`}
|
className={`${styles.actionButton} ${allSelected ? styles.active : ''}`}
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className={styles.loading}>Lade...</div>
|
<div className={styles.loading}>Lade...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.checkboxList}>
|
<div className={styles.checkboxList}>
|
||||||
{allowedProviders.map((provider) => (
|
{allowedProviders.map((provider) => (
|
||||||
<label
|
<label
|
||||||
key={provider}
|
key={provider}
|
||||||
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
|
className={`${styles.checkboxItem} ${disabled ? styles.disabled : ''}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={effectiveSelection.includes(provider)}
|
checked={effectiveSelection.includes(provider)}
|
||||||
onChange={() => handleToggle(provider)}
|
onChange={() => _handleToggle(provider)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
|
<span className={styles.icon}>{PROVIDER_ICONS[provider] || '🔌'}</span>
|
||||||
|
|
@ -260,12 +322,8 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAllSelected && !loading && (
|
<div className={styles.hint}>{summaryHint}</div>
|
||||||
<div className={styles.hint}>
|
|
||||||
Alle Provider aktiv (kein Filter)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -288,7 +346,7 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
|
||||||
if (providers.length === 0) {
|
if (providers.length === 0) {
|
||||||
return <span className={styles.allProviders}>Alle Provider</span>;
|
return <span className={styles.allProviders}>Alle Provider</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.providerBadges} ${className || ''}`}>
|
<div className={`${styles.providerBadges} ${className || ''}`}>
|
||||||
{providers.map((provider) => (
|
{providers.map((provider) => (
|
||||||
|
|
@ -300,5 +358,4 @@ export const ProviderBadges: React.FC<ProviderBadgesProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default export
|
|
||||||
export default ProviderSelect;
|
export default ProviderSelect;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,19 @@
|
||||||
export {
|
export {
|
||||||
ProviderSelect,
|
ProviderSelect,
|
||||||
ProviderMultiSelect,
|
ProviderMultiSelect,
|
||||||
ProviderBadges
|
ProviderBadges,
|
||||||
} from './ProviderSelector';
|
} from './ProviderSelector';
|
||||||
|
|
||||||
|
export {
|
||||||
|
PROVIDER_ALL,
|
||||||
|
_defaultProviderSelection,
|
||||||
|
_resolveProviders,
|
||||||
|
_isAllSelected,
|
||||||
|
_isNoneSelected,
|
||||||
|
_migrateFromLegacy,
|
||||||
|
_toBackendProviders,
|
||||||
|
} from './ProviderSelector';
|
||||||
|
|
||||||
|
export type { ProviderSelection } from './ProviderSelector';
|
||||||
|
|
||||||
export { default } from './ProviderSelector';
|
export { default } from './ProviderSelector';
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
||||||
import { WorkflowFile } from '../../../hooks/usePlayground';
|
import { WorkflowFile } from '../../../hooks/usePlayground';
|
||||||
import styles from './ConnectedFilesList.module.css';
|
import styles from './ConnectedFilesList.module.css';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
|
|
||||||
export interface ConnectedFilesListActionButton {
|
export interface ConnectedFilesListActionButton {
|
||||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
|
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
|
||||||
|
|
@ -240,7 +241,7 @@ export function ConnectedFilesList({
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fileMeta}>
|
<div className={styles.fileMeta}>
|
||||||
<span className={styles.fileSize}>
|
<span className={styles.fileSize}>
|
||||||
{formatFileSize(file.fileSize)}
|
{formatBinaryDataSizeBytes(file.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
{file.source && (
|
{file.source && (
|
||||||
<span className={styles.fileSource}>
|
<span className={styles.fileSource}>
|
||||||
|
|
@ -371,13 +372,5 @@ export function ConnectedFilesList({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConnectedFilesList;
|
export default ConnectedFilesList;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
* Utility functions for message formatting and styling
|
* Utility functions for message formatting and styling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a timestamp to a readable date/time string
|
* Formats a timestamp to a readable date/time string
|
||||||
* Handles both Unix timestamps in seconds and milliseconds
|
* Handles both Unix timestamps in seconds and milliseconds
|
||||||
|
|
@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */
|
||||||
* Formats file size to human-readable format
|
export const formatFileSize = formatBinaryDataSizeBytes;
|
||||||
*/
|
|
||||||
export const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes === 0) return '0 Bytes';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets status badge color class based on status
|
* Gets status badge color class based on status
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ export interface MessageDocument {
|
||||||
taskNumber: number;
|
taskNumber: number;
|
||||||
actionNumber: number;
|
actionNumber: number;
|
||||||
actionId: string;
|
actionId: string;
|
||||||
|
documentName?: string;
|
||||||
|
validationMetadata?: {
|
||||||
|
neutralized?: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
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)
|
* UserConnection (Level 1, loaded on mount)
|
||||||
* └─ Service (Level 2, loaded when connection expanded)
|
* └─ Service (Level 2, loaded when connection expanded)
|
||||||
* └─ Folder / Site / File (Level 3+, loaded when service/folder expanded)
|
* └─ Folder / Site / File (Level 3+, loaded when service/folder expanded)
|
||||||
*
|
*
|
||||||
* Each folder node can be added as a DataSource for this workspace instance.
|
* Feature Data tree:
|
||||||
|
* MandateGroup
|
||||||
|
* └─ FeatureConnection (feature instance)
|
||||||
|
* └─ FeatureTable (tables exposed by that instance)
|
||||||
|
*
|
||||||
|
* Active Sources sections show scope-cycling and neutralize-toggle buttons.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import api from '../../../api';
|
import type { UdbContext } from './UnifiedDataBar';
|
||||||
import { getPageIcon } from '../../../config/pageRegistry';
|
import api from '../../api';
|
||||||
import type { DataSource, FeatureDataSource } from './useWorkspace';
|
import { getPageIcon } from '../../config/pageRegistry';
|
||||||
|
import styles from './SourcesTab.module.css';
|
||||||
|
|
||||||
/* ─── Types ─────────────────────────────────────────────────────────── */
|
/* ─── Types (inline, no external imports) ────────────────────────────── */
|
||||||
|
|
||||||
|
interface UdbDataSource {
|
||||||
|
id: string;
|
||||||
|
connectionId: string;
|
||||||
|
sourceType: string;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
displayPath?: string;
|
||||||
|
scope: string;
|
||||||
|
neutralize: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UdbFeatureDataSource {
|
||||||
|
id: string;
|
||||||
|
featureInstanceId: string;
|
||||||
|
featureCode: string;
|
||||||
|
tableName: string;
|
||||||
|
objectKey: string;
|
||||||
|
label: string;
|
||||||
|
scope: string;
|
||||||
|
neutralize: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -27,7 +55,6 @@ interface TreeNode {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
service?: string;
|
service?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
/** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */
|
|
||||||
displayPath?: string;
|
displayPath?: string;
|
||||||
authority?: string;
|
authority?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -58,15 +85,13 @@ interface FeatureTableNode {
|
||||||
fields: string[];
|
fields: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DataSourcePanelProps {
|
/* ─── Props ──────────────────────────────────────────────────────────── */
|
||||||
instanceId: string;
|
|
||||||
dataSources: DataSource[];
|
interface SourcesTabProps {
|
||||||
featureDataSources: FeatureDataSource[];
|
context: UdbContext;
|
||||||
onRefresh: () => void;
|
|
||||||
onRefreshFeatureDataSources: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Icons ─────────────────────────────────────────────────────────── */
|
/* ─── Icons ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const _AUTHORITY_ICONS: Record<string, string> = {
|
const _AUTHORITY_ICONS: Record<string, string> = {
|
||||||
msft: '\uD83D\uDFE6',
|
msft: '\uD83D\uDFE6',
|
||||||
|
|
@ -113,6 +138,40 @@ function _getSourceIcon(sourceType: string): string {
|
||||||
return map[sourceType] || '\uD83D\uDCC1';
|
return map[sourceType] || '\uD83D\uDCC1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Scope / Neutralize constants ───────────────────────────────────── */
|
||||||
|
|
||||||
|
const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate'];
|
||||||
|
|
||||||
|
const _SCOPE_ICONS: Record<string, string> = {
|
||||||
|
personal: '\uD83D\uDC64',
|
||||||
|
featureInstance: '\uD83D\uDC65',
|
||||||
|
mandate: '\uD83C\uDFE2',
|
||||||
|
global: '\uD83C\uDF10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SCOPE_LABELS: Record<string, string> = {
|
||||||
|
personal: 'Personal',
|
||||||
|
featureInstance: 'Feature Instance',
|
||||||
|
mandate: 'Mandate',
|
||||||
|
global: 'Global',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _nextScope(current: string): string {
|
||||||
|
const idx = _SCOPE_ORDER.indexOf(current);
|
||||||
|
if (idx === -1) return _SCOPE_ORDER[0];
|
||||||
|
return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tree helpers ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
|
||||||
|
return nodes.map(n => {
|
||||||
|
if (n.key === key) return updater(n);
|
||||||
|
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function _mapFeatureTreeUpdate(
|
function _mapFeatureTreeUpdate(
|
||||||
prev: MandateGroupNode[],
|
prev: MandateGroupNode[],
|
||||||
featureInstanceId: string,
|
featureInstanceId: string,
|
||||||
|
|
@ -137,14 +196,14 @@ function _findFeatureInstanceMeta(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _personalDataSourceHoverTitle(connLabel: string, ds: DataSource): string {
|
function _personalDataSourceHoverTitle(connLabel: string, ds: UdbDataSource): string {
|
||||||
const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || '';
|
const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || '';
|
||||||
return pathPart ? `${connLabel} / ${pathPart}` : connLabel;
|
return pathPart ? `${connLabel} / ${pathPart}` : connLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _featureDataSourceHoverTitle(
|
function _featureDataSourceHoverTitle(
|
||||||
meta: { mandateLabel: string; instanceLabel: string } | null,
|
meta: { mandateLabel: string; instanceLabel: string } | null,
|
||||||
fds: FeatureDataSource,
|
fds: UdbFeatureDataSource,
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (meta) {
|
if (meta) {
|
||||||
|
|
@ -160,24 +219,153 @@ function _featureDataSourceHoverTitle(
|
||||||
return parts.join(' / ');
|
return parts.join(' / ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Component ─────────────────────────────────────────────────────── */
|
/* ─── Data fetching (module-level) ───────────────────────────────────── */
|
||||||
|
|
||||||
export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
|
||||||
instanceId,
|
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
|
||||||
dataSources,
|
const services = res.data.services || [];
|
||||||
featureDataSources,
|
return services.map((s: any) => ({
|
||||||
onRefresh,
|
key: `svc-${connectionId}-${s.service}`,
|
||||||
onRefreshFeatureDataSources,
|
label: s.label || s.service,
|
||||||
}) => {
|
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
|
||||||
|
type: 'service' as const,
|
||||||
|
expanded: false,
|
||||||
|
loading: false,
|
||||||
|
children: null,
|
||||||
|
connectionId,
|
||||||
|
service: s.service,
|
||||||
|
path: '/',
|
||||||
|
displayPath: s.label || s.service,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _browseService(
|
||||||
|
instanceId: string,
|
||||||
|
connectionId: string,
|
||||||
|
service: string,
|
||||||
|
path: string,
|
||||||
|
parentDisplayPath: string | undefined,
|
||||||
|
): Promise<TreeNode[]> {
|
||||||
|
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
|
||||||
|
params: { service, path },
|
||||||
|
});
|
||||||
|
const items = res.data.items || [];
|
||||||
|
return items.map((entry: any, idx: number) => {
|
||||||
|
const seg = entry.name || '';
|
||||||
|
const displayPath = parentDisplayPath
|
||||||
|
? `${parentDisplayPath} / ${seg}`
|
||||||
|
: seg;
|
||||||
|
return {
|
||||||
|
key: `item-${connectionId}-${service}-${entry.path || idx}`,
|
||||||
|
label: entry.name,
|
||||||
|
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
|
||||||
|
type: entry.isFolder ? 'folder' as const : 'file' as const,
|
||||||
|
expanded: false,
|
||||||
|
loading: false,
|
||||||
|
children: entry.isFolder ? null : [],
|
||||||
|
connectionId,
|
||||||
|
service,
|
||||||
|
path: entry.path,
|
||||||
|
displayPath,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fileIcon(name: string): string {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
|
||||||
|
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
|
||||||
|
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
|
||||||
|
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
|
||||||
|
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
|
||||||
|
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
|
||||||
|
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
|
||||||
|
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
|
||||||
|
};
|
||||||
|
return map[ext] || '\uD83D\uDCC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Spinner (inline) ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _Spinner(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: 10, height: 10,
|
||||||
|
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 0.6s linear infinite',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Component ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const SourcesTab: React.FC<SourcesTabProps> = ({ context }) => {
|
||||||
|
const instanceId = context.instanceId;
|
||||||
|
|
||||||
|
/* ── Active sources (fetched internally) ── */
|
||||||
|
const [dataSources, setDataSources] = useState<UdbDataSource[]>([]);
|
||||||
|
const [featureDataSources, setFeatureDataSources] = useState<UdbFeatureDataSource[]>([]);
|
||||||
|
|
||||||
|
/* ── Browse tree state ── */
|
||||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||||
const [loadingRoot, setLoadingRoot] = useState(false);
|
const [loadingRoot, setLoadingRoot] = useState(false);
|
||||||
const [addingPath, setAddingPath] = useState<string | null>(null);
|
const [addingPath, setAddingPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/* ── Feature tree state ── */
|
||||||
const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
|
const [featureTree, setFeatureTree] = useState<MandateGroupNode[]>([]);
|
||||||
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||||||
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
|
const [addingFeatureKey, setAddingFeatureKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
|
useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []);
|
||||||
|
|
||||||
|
/* ── Fetch active personal data sources ── */
|
||||||
|
const _fetchDataSources = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/datasources`)
|
||||||
|
.then(res => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
connectionId: d.connectionId,
|
||||||
|
sourceType: d.sourceType,
|
||||||
|
path: d.path,
|
||||||
|
label: d.label,
|
||||||
|
displayPath: d.displayPath,
|
||||||
|
scope: d.scope || 'personal',
|
||||||
|
neutralize: d.neutralize ?? false,
|
||||||
|
}));
|
||||||
|
setDataSources(list);
|
||||||
|
})
|
||||||
|
.catch(() => { if (mountedRef.current) setDataSources([]); });
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
/* ── Fetch active feature data sources ── */
|
||||||
|
const _fetchFeatureDataSources = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/feature-datasources`)
|
||||||
|
.then(res => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
featureInstanceId: d.featureInstanceId,
|
||||||
|
featureCode: d.featureCode,
|
||||||
|
tableName: d.tableName,
|
||||||
|
objectKey: d.objectKey,
|
||||||
|
label: d.label,
|
||||||
|
scope: d.scope || 'personal',
|
||||||
|
neutralize: d.neutralize ?? false,
|
||||||
|
}));
|
||||||
|
setFeatureDataSources(list);
|
||||||
|
})
|
||||||
|
.catch(() => { if (mountedRef.current) setFeatureDataSources([]); });
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]);
|
||||||
|
useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]);
|
||||||
|
|
||||||
/* ── Load Level 1: UserConnections ── */
|
/* ── Load Level 1: UserConnections ── */
|
||||||
const _loadConnections = useCallback(() => {
|
const _loadConnections = useCallback(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -271,23 +459,23 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
label: node.label,
|
label: node.label,
|
||||||
displayPath: node.displayPath || node.label,
|
displayPath: node.displayPath || node.label,
|
||||||
});
|
});
|
||||||
onRefresh();
|
_fetchDataSources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add data source:', err);
|
console.error('Failed to add data source:', err);
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current) setAddingPath(null);
|
if (mountedRef.current) setAddingPath(null);
|
||||||
}
|
}
|
||||||
}, [instanceId, onRefresh]);
|
}, [instanceId, _fetchDataSources]);
|
||||||
|
|
||||||
/* ── Remove DataSource ── */
|
/* ── Remove DataSource ── */
|
||||||
const _removeDatasource = useCallback(async (dsId: string) => {
|
const _removeDatasource = useCallback(async (dsId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
|
await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`);
|
||||||
onRefresh();
|
_fetchDataSources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove data source:', err);
|
console.error('Failed to remove data source:', err);
|
||||||
}
|
}
|
||||||
}, [instanceId, onRefresh]);
|
}, [instanceId, _fetchDataSources]);
|
||||||
|
|
||||||
/* ── Check if a path is already added ── */
|
/* ── Check if a path is already added ── */
|
||||||
const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
|
const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => {
|
||||||
|
|
@ -296,6 +484,50 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
);
|
);
|
||||||
}, [dataSources]);
|
}, [dataSources]);
|
||||||
|
|
||||||
|
/* ── Scope change (personal data source, optimistic) ── */
|
||||||
|
const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => {
|
||||||
|
const newScope = _nextScope(ds.scope);
|
||||||
|
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d));
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope });
|
||||||
|
} catch {
|
||||||
|
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Neutralize toggle (personal data source, optimistic) ── */
|
||||||
|
const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => {
|
||||||
|
const newValue = !ds.neutralize;
|
||||||
|
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d));
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue });
|
||||||
|
} catch {
|
||||||
|
setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Scope change (feature data source, optimistic) ── */
|
||||||
|
const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => {
|
||||||
|
const newScope = _nextScope(fds.scope);
|
||||||
|
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d));
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope });
|
||||||
|
} catch {
|
||||||
|
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ── Neutralize toggle (feature data source, optimistic) ── */
|
||||||
|
const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => {
|
||||||
|
const newValue = !fds.neutralize;
|
||||||
|
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d));
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue });
|
||||||
|
} catch {
|
||||||
|
setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
/* ── Feature Connections: Load Level 1 ── */
|
/* ── Feature Connections: Load Level 1 ── */
|
||||||
const _loadFeatureConnections = useCallback(() => {
|
const _loadFeatureConnections = useCallback(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -384,23 +616,23 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
objectKey: table.objectKey,
|
objectKey: table.objectKey,
|
||||||
label: table.label?.en || table.label?.de || table.tableName,
|
label: table.label?.en || table.label?.de || table.tableName,
|
||||||
});
|
});
|
||||||
onRefreshFeatureDataSources();
|
_fetchFeatureDataSources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add feature data source:', err);
|
console.error('Failed to add feature data source:', err);
|
||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current) setAddingFeatureKey(null);
|
if (mountedRef.current) setAddingFeatureKey(null);
|
||||||
}
|
}
|
||||||
}, [instanceId, onRefreshFeatureDataSources]);
|
}, [instanceId, _fetchFeatureDataSources]);
|
||||||
|
|
||||||
/* ── Feature: Remove FeatureDataSource ── */
|
/* ── Feature: Remove FeatureDataSource ── */
|
||||||
const _removeFeatureDataSource = useCallback(async (fdsId: string) => {
|
const _removeFeatureDataSource = useCallback(async (fdsId: string) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
|
await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`);
|
||||||
onRefreshFeatureDataSources();
|
_fetchFeatureDataSources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove feature data source:', err);
|
console.error('Failed to remove feature data source:', err);
|
||||||
}
|
}
|
||||||
}, [instanceId, onRefreshFeatureDataSources]);
|
}, [instanceId, _fetchFeatureDataSources]);
|
||||||
|
|
||||||
/* ── Feature: check if table already added ── */
|
/* ── Feature: check if table already added ── */
|
||||||
const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
|
const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => {
|
||||||
|
|
@ -409,9 +641,11 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
);
|
);
|
||||||
}, [featureDataSources]);
|
}, [featureDataSources]);
|
||||||
|
|
||||||
|
/* ── Render ── */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 8, fontSize: 13 }}>
|
<div className={styles.sourcesTab} style={{ padding: 8, fontSize: 13 }}>
|
||||||
{/* Active DataSources */}
|
{/* ── Active Personal Sources ── */}
|
||||||
{dataSources.length > 0 && (
|
{dataSources.length > 0 && (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
||||||
|
|
@ -434,6 +668,27 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{connLabel} – {folder}
|
{connLabel} – {folder}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => _cyclePersonalScope(ds)}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||||||
|
}}
|
||||||
|
title={`Scope: ${_SCOPE_LABELS[ds.scope] || ds.scope} → ${_SCOPE_LABELS[_nextScope(ds.scope)]}`}
|
||||||
|
>
|
||||||
|
{_SCOPE_ICONS[ds.scope] || _SCOPE_ICONS.personal}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => _togglePersonalNeutralize(ds)}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||||||
|
opacity: ds.neutralize ? 1 : 0.35,
|
||||||
|
}}
|
||||||
|
title={ds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => _removeDatasource(ds.id)}
|
onClick={() => _removeDatasource(ds.id)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
||||||
|
|
@ -448,7 +703,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tree header */}
|
{/* ── Browse Sources header ── */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
||||||
Browse Sources
|
Browse Sources
|
||||||
|
|
@ -462,7 +717,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tree */}
|
{/* ── Browse Sources tree ── */}
|
||||||
{loadingRoot && tree.length === 0 && (
|
{loadingRoot && tree.length === 0 && (
|
||||||
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
Loading connections...
|
Loading connections...
|
||||||
|
|
@ -487,10 +742,10 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* ── Feature Data Section ── */}
|
{/* ── Divider ── */}
|
||||||
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
|
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '12px 0 8px' }} />
|
||||||
|
|
||||||
{/* Active Feature Data Sources */}
|
{/* ── Active Feature Sources ── */}
|
||||||
{featureDataSources.length > 0 && (
|
{featureDataSources.length > 0 && (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', marginBottom: 4 }}>
|
||||||
|
|
@ -500,33 +755,55 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
|
const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId);
|
||||||
const fdsConnLabel = meta?.instanceLabel || fds.tableName;
|
const fdsConnLabel = meta?.instanceLabel || fds.tableName;
|
||||||
return (
|
return (
|
||||||
<div key={fds.id} style={{
|
<div key={fds.id} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
padding: '4px 6px', borderRadius: 4, marginBottom: 2,
|
||||||
background: '#7b1fa218',
|
background: '#7b1fa218',
|
||||||
borderLeft: '3px solid #7b1fa2',
|
borderLeft: '3px solid #7b1fa2',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}} title={_featureDataSourceHoverTitle(meta, fds)}>
|
}} title={_featureDataSourceHoverTitle(meta, fds)}>
|
||||||
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
|
<span style={{ fontSize: 12, flexShrink: 0, display: 'flex', alignItems: 'center', color: '#7b1fa2' }}>
|
||||||
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{fdsConnLabel} – {fds.tableName}
|
{fdsConnLabel} – {fds.tableName}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => _removeFeatureDataSource(fds.id)}
|
onClick={() => _cycleFeatureScope(fds)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
style={{
|
||||||
title="Entfernen"
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
>
|
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||||||
{'\u2715'}
|
}}
|
||||||
</button>
|
title={`Scope: ${_SCOPE_LABELS[fds.scope] || fds.scope} → ${_SCOPE_LABELS[_nextScope(fds.scope)]}`}
|
||||||
</div>
|
>
|
||||||
); })}
|
{_SCOPE_ICONS[fds.scope] || _SCOPE_ICONS.personal}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => _toggleFeatureNeutralize(fds)}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 13, padding: '0 2px', lineHeight: 1,
|
||||||
|
opacity: fds.neutralize ? 1 : 0.35,
|
||||||
|
}}
|
||||||
|
title={fds.neutralize ? 'Neutralize: ON (click to deactivate)' : 'Neutralize: OFF (click to activate)'}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => _removeFeatureDataSource(fds.id)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: '#999', padding: '0 2px' }}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
{'\u2715'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
|
<div style={{ height: 1, background: 'var(--border-color, #e0e0e0)', margin: '8px 0' }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feature Connections Tree */}
|
{/* ── Feature Data header ── */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
<span style={{ fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>
|
||||||
Feature Data
|
Feature Data
|
||||||
|
|
@ -540,6 +817,7 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Feature Data tree ── */}
|
||||||
{loadingFeatures && featureTree.length === 0 && (
|
{loadingFeatures && featureTree.length === 0 && (
|
||||||
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
Loading feature instances...
|
Loading feature instances...
|
||||||
|
|
@ -567,9 +845,9 @@ export const DataSourcePanel: React.FC<DataSourcePanelProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */
|
/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */
|
||||||
|
|
||||||
interface TreeNodeViewProps {
|
interface _TreeNodeViewProps {
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
depth: number;
|
depth: number;
|
||||||
onToggle: (node: TreeNode) => void;
|
onToggle: (node: TreeNode) => void;
|
||||||
|
|
@ -578,7 +856,7 @@ interface TreeNodeViewProps {
|
||||||
addingPath: string | null;
|
addingPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
|
const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({
|
||||||
node, depth, onToggle, onAdd, isAdded, addingPath,
|
node, depth, onToggle, onAdd, isAdded, addingPath,
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
@ -646,7 +924,6 @@ const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Children */}
|
|
||||||
{node.expanded && node.children && node.children.length > 0 && (
|
{node.expanded && node.children && node.children.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{node.children.map(child => (
|
{node.children.map(child => (
|
||||||
|
|
@ -672,9 +949,9 @@ const _TreeNodeView: React.FC<TreeNodeViewProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── MandateGroupView (mandate + feature instances) ───────────────── */
|
/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */
|
||||||
|
|
||||||
interface MandateGroupViewProps {
|
interface _MandateGroupViewProps {
|
||||||
group: MandateGroupNode;
|
group: MandateGroupNode;
|
||||||
onToggleGroup: (mandateId: string) => void;
|
onToggleGroup: (mandateId: string) => void;
|
||||||
onToggleFeature: (node: FeatureConnectionNode) => void;
|
onToggleFeature: (node: FeatureConnectionNode) => void;
|
||||||
|
|
@ -683,7 +960,7 @@ interface MandateGroupViewProps {
|
||||||
addingKey: string | null;
|
addingKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _MandateGroupView: React.FC<MandateGroupViewProps> = ({
|
const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({
|
||||||
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
|
group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey,
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
@ -729,9 +1006,9 @@ const _MandateGroupView: React.FC<MandateGroupViewProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── FeatureNodeView (feature instance + tables) ─────────────────── */
|
/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */
|
||||||
|
|
||||||
interface FeatureNodeViewProps {
|
interface _FeatureNodeViewProps {
|
||||||
node: FeatureConnectionNode;
|
node: FeatureConnectionNode;
|
||||||
onToggle: (node: FeatureConnectionNode) => void;
|
onToggle: (node: FeatureConnectionNode) => void;
|
||||||
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
||||||
|
|
@ -739,7 +1016,7 @@ interface FeatureNodeViewProps {
|
||||||
addingKey: string | null;
|
addingKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({
|
const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({
|
||||||
node, onToggle, onAddTable, isTableAdded, addingKey,
|
node, onToggle, onAddTable, isTableAdded, addingKey,
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
@ -797,7 +1074,9 @@ const _FeatureNodeView: React.FC<FeatureNodeViewProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FeatureTableRowProps {
|
/* ─── FeatureTableRow ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface _FeatureTableRowProps {
|
||||||
featureNode: FeatureConnectionNode;
|
featureNode: FeatureConnectionNode;
|
||||||
table: FeatureTableNode;
|
table: FeatureTableNode;
|
||||||
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void;
|
||||||
|
|
@ -805,7 +1084,7 @@ interface FeatureTableRowProps {
|
||||||
isAdding: boolean;
|
isAdding: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({
|
const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({
|
||||||
featureNode, table, onAdd, isAdded, isAdding,
|
featureNode, table, onAdd, isAdded, isAdding,
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
@ -852,92 +1131,4 @@ const _FeatureTableRow: React.FC<FeatureTableRowProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ─── Spinner (inline) ──────────────────────────────────────────────── */
|
export default SourcesTab;
|
||||||
|
|
||||||
function _Spinner(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-block', width: 10, height: 10,
|
|
||||||
border: '1.5px solid #ccc', borderTopColor: '#1976d2',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 0.6s linear infinite',
|
|
||||||
}} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Data fetching ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function _loadServices(instanceId: string, connectionId: string): Promise<TreeNode[]> {
|
|
||||||
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`);
|
|
||||||
const services = res.data.services || [];
|
|
||||||
return services.map((s: any) => ({
|
|
||||||
key: `svc-${connectionId}-${s.service}`,
|
|
||||||
label: s.label || s.service,
|
|
||||||
icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2',
|
|
||||||
type: 'service' as const,
|
|
||||||
expanded: false,
|
|
||||||
loading: false,
|
|
||||||
children: null,
|
|
||||||
connectionId,
|
|
||||||
service: s.service,
|
|
||||||
path: '/',
|
|
||||||
displayPath: s.label || s.service,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _browseService(
|
|
||||||
instanceId: string,
|
|
||||||
connectionId: string,
|
|
||||||
service: string,
|
|
||||||
path: string,
|
|
||||||
parentDisplayPath: string | undefined,
|
|
||||||
): Promise<TreeNode[]> {
|
|
||||||
const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, {
|
|
||||||
params: { service, path },
|
|
||||||
});
|
|
||||||
const items = res.data.items || [];
|
|
||||||
return items.map((entry: any, idx: number) => {
|
|
||||||
const seg = entry.name || '';
|
|
||||||
const displayPath = parentDisplayPath
|
|
||||||
? `${parentDisplayPath} / ${seg}`
|
|
||||||
: seg;
|
|
||||||
return {
|
|
||||||
key: `item-${connectionId}-${service}-${entry.path || idx}`,
|
|
||||||
label: entry.name,
|
|
||||||
icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name),
|
|
||||||
type: entry.isFolder ? 'folder' as const : 'file' as const,
|
|
||||||
expanded: false,
|
|
||||||
loading: false,
|
|
||||||
children: entry.isFolder ? null : [],
|
|
||||||
connectionId,
|
|
||||||
service,
|
|
||||||
path: entry.path,
|
|
||||||
displayPath,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _fileIcon(name: string): string {
|
|
||||||
const ext = name.split('.').pop()?.toLowerCase() || '';
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD',
|
|
||||||
xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA',
|
|
||||||
ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8',
|
|
||||||
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB',
|
|
||||||
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F',
|
|
||||||
zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6',
|
|
||||||
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5',
|
|
||||||
mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC',
|
|
||||||
};
|
|
||||||
return map[ext] || '\uD83D\uDCC4';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tree map utility ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] {
|
|
||||||
return nodes.map(n => {
|
|
||||||
if (n.key === key) return updater(n);
|
|
||||||
if (n.children) return { ...n, children: _mapTree(n.children, key, updater) };
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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
|
return false; // Don't show readonly fields in edit form
|
||||||
}
|
}
|
||||||
// Also filter out common non-editable fields
|
// Also filter out common non-editable fields
|
||||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -305,7 +305,7 @@ export function useMandates() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Filter out ID fields and other auto-generated fields
|
// Filter out ID fields and other auto-generated fields
|
||||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,7 @@ export function useRbacRoles() {
|
||||||
return false; // Don't show readonly fields in edit form
|
return false; // Don't show readonly fields in edit form
|
||||||
}
|
}
|
||||||
// Also filter out common non-editable fields
|
// Also filter out common non-editable fields
|
||||||
const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -346,7 +346,7 @@ export function useRbacRoles() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Filter out ID fields and other auto-generated fields
|
// Filter out ID fields and other auto-generated fields
|
||||||
const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ export function useRbacRules() {
|
||||||
return false; // Don't show readonly fields in edit form
|
return false; // Don't show readonly fields in edit form
|
||||||
}
|
}
|
||||||
// Also filter out common non-editable fields
|
// Also filter out common non-editable fields
|
||||||
const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -322,7 +322,7 @@ export function useRbacRules() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Filter out ID fields and other auto-generated fields
|
// Filter out ID fields and other auto-generated fields
|
||||||
const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,7 @@ export function useRegister() {
|
||||||
interface GoogleAuthResponse {
|
interface GoogleAuthResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
tokenType: string;
|
tokenType: string;
|
||||||
|
isNewUser?: boolean;
|
||||||
user: {
|
user: {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
|
||||||
|
|
@ -536,7 +536,7 @@ export function useAutomationTemplates() {
|
||||||
return await fetchAutomationTemplateById(request, templateId);
|
return await fetchAutomationTemplateById(request, templateId);
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const createTemplate = useCallback(async (data: Omit<AutomationTemplate, 'id' | '_createdAt' | '_createdBy'>) => {
|
const createTemplate = useCallback(async (data: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>) => {
|
||||||
return await createAutomationTemplateApi(request, data);
|
return await createAutomationTemplateApi(request, data);
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export type {
|
||||||
MandateUserSummary,
|
MandateUserSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi';
|
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for user billing operations
|
* Hook for user billing operations
|
||||||
|
|
@ -217,34 +217,21 @@ export function useBillingAdmin(mandateId?: string) {
|
||||||
}
|
}
|
||||||
}, [request, mandateId]);
|
}, [request, mandateId]);
|
||||||
|
|
||||||
// Update settings — after billing model change, reload dependent data (accounts / users / tx)
|
|
||||||
const saveSettings = useCallback(
|
const saveSettings = useCallback(
|
||||||
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
|
async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => {
|
||||||
const mId = targetMandateId || mandateId;
|
const mId = targetMandateId || mandateId;
|
||||||
if (!mId) return null;
|
if (!mId) return null;
|
||||||
|
|
||||||
const previousModel = settings?.billingModel;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
|
const data = await updateSettingsAdmin(request, mId, settingsUpdate);
|
||||||
setSettings(data);
|
setSettings(data);
|
||||||
const newModel = settingsUpdate.billingModel;
|
|
||||||
const modelChanged =
|
|
||||||
newModel !== undefined && newModel !== null && newModel !== previousModel;
|
|
||||||
if (modelChanged) {
|
|
||||||
await Promise.all([
|
|
||||||
loadAccounts(mId),
|
|
||||||
loadTransactions(mId, 100),
|
|
||||||
loadUsers(mId),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving billing settings:', err);
|
console.error('Error saving billing settings:', err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers]
|
[request, mandateId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add credit (manual, admin)
|
// Add credit (manual, admin)
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export function useConfirm() {
|
||||||
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||||
border: '1px solid var(--color-border, #444)',
|
border: '1px solid var(--color-border, #444)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'var(--text-secondary, #aaa)',
|
color: 'var(--text-primary, #e8e8e8)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,11 @@ export function useTablePermission(tableName: string) {
|
||||||
canDelete: hasAccess(permission.delete),
|
canDelete: hasAccess(permission.delete),
|
||||||
|
|
||||||
// Record-basierte Prüfungen
|
// Record-basierte Prüfungen
|
||||||
canReadRecord: (record: { _createdBy?: string }) =>
|
canReadRecord: (record: { sysCreatedBy?: string }) =>
|
||||||
canAccessRecord(permission.read, record, userId),
|
canAccessRecord(permission.read, record, userId),
|
||||||
canUpdateRecord: (record: { _createdBy?: string }) =>
|
canUpdateRecord: (record: { sysCreatedBy?: string }) =>
|
||||||
canAccessRecord(permission.update, record, userId),
|
canAccessRecord(permission.update, record, userId),
|
||||||
canDeleteRecord: (record: { _createdBy?: string }) =>
|
canDeleteRecord: (record: { sysCreatedBy?: string }) =>
|
||||||
canAccessRecord(permission.delete, record, userId),
|
canAccessRecord(permission.delete, record, userId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +296,7 @@ export function useInstancePermissions(): InstancePermissions | undefined {
|
||||||
*/
|
*/
|
||||||
export function useCanEditRecord(
|
export function useCanEditRecord(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
record: { _createdBy?: string } | undefined,
|
record: { sysCreatedBy?: string } | undefined,
|
||||||
userId: string
|
userId: string
|
||||||
): boolean {
|
): boolean {
|
||||||
const { update } = useTablePermission(tableName);
|
const { update } = useTablePermission(tableName);
|
||||||
|
|
@ -311,7 +311,7 @@ export function useCanEditRecord(
|
||||||
*/
|
*/
|
||||||
export function useCanDeleteRecord(
|
export function useCanDeleteRecord(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
record: { _createdBy?: string } | undefined,
|
record: { sysCreatedBy?: string } | undefined,
|
||||||
userId: string
|
userId: string
|
||||||
): boolean {
|
): boolean {
|
||||||
const { delete: deleteLevel } = useTablePermission(tableName);
|
const { delete: deleteLevel } = useTablePermission(tableName);
|
||||||
|
|
@ -329,7 +329,7 @@ interface PermissionGateProps {
|
||||||
table?: string;
|
table?: string;
|
||||||
view?: string;
|
view?: string;
|
||||||
action?: 'view' | 'read' | 'create' | 'update' | 'delete';
|
action?: 'view' | 'read' | 'create' | 'update' | 'delete';
|
||||||
record?: { _createdBy?: string };
|
record?: { sysCreatedBy?: string };
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
fallback?: React.ReactNode;
|
fallback?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export interface FeatureInstance {
|
||||||
uiLabel: string;
|
uiLabel: string;
|
||||||
order: number;
|
order: number;
|
||||||
views: FeatureView[];
|
views: FeatureView[];
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Feature within a mandate */
|
/** Feature within a mandate */
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,48 @@ import api from '../api';
|
||||||
|
|
||||||
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
|
const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']);
|
||||||
|
|
||||||
|
/** API uses PowerOnModel.sysCreatedAt (seconds); legacy clients used createdAt. */
|
||||||
|
function _coerceToUnixSeconds(value: unknown): number | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value > 1e12 ? value / 1000 : value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const asNum = Number(trimmed);
|
||||||
|
if (!Number.isNaN(asNum)) {
|
||||||
|
return asNum > 1e12 ? asNum / 1000 : asNum;
|
||||||
|
}
|
||||||
|
const parsed = Date.parse(trimmed);
|
||||||
|
if (!Number.isNaN(parsed)) return parsed / 1000;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeNotificationFromApi(raw: Record<string, unknown>): UserNotification {
|
||||||
|
const partial = raw as unknown as UserNotification;
|
||||||
|
const createdAt =
|
||||||
|
_coerceToUnixSeconds(raw.createdAt) ??
|
||||||
|
_coerceToUnixSeconds(raw.sysCreatedAt) ??
|
||||||
|
(Number.isFinite(partial.createdAt) ? partial.createdAt : 0) ??
|
||||||
|
0;
|
||||||
|
return {
|
||||||
|
...partial,
|
||||||
|
createdAt,
|
||||||
|
readAt: _coerceToUnixSeconds(raw.readAt) ?? partial.readAt,
|
||||||
|
actionedAt: _coerceToUnixSeconds(raw.actionedAt) ?? partial.actionedAt,
|
||||||
|
expiresAt: _coerceToUnixSeconds(raw.expiresAt) ?? partial.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeNotificationList(data: unknown): UserNotification[] {
|
||||||
|
if (!Array.isArray(data)) return [];
|
||||||
|
return data.map(item =>
|
||||||
|
_normalizeNotificationFromApi(item && typeof item === 'object' ? (item as Record<string, unknown>) : {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface NotificationAction {
|
export interface NotificationAction {
|
||||||
actionId: string;
|
actionId: string;
|
||||||
|
|
@ -30,6 +72,7 @@ export interface UserNotification {
|
||||||
actions?: NotificationAction[];
|
actions?: NotificationAction[];
|
||||||
actionTaken?: string;
|
actionTaken?: string;
|
||||||
actionResult?: string;
|
actionResult?: string;
|
||||||
|
/** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
readAt?: number;
|
readAt?: number;
|
||||||
actionedAt?: number;
|
actionedAt?: number;
|
||||||
|
|
@ -74,7 +117,7 @@ export function useNotifications() {
|
||||||
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
|
const url = `/api/notifications${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
const response = await api.get(url);
|
const response = await api.get(url);
|
||||||
const data = response.data as UserNotification[];
|
const data = _normalizeNotificationList(response.data);
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
return data;
|
return data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -101,9 +144,9 @@ export function useNotifications() {
|
||||||
const listRes = await api.get('/api/notifications', {
|
const listRes = await api.get('/api/notifications', {
|
||||||
params: { status: 'unread', limit: 25 },
|
params: { status: 'unread', limit: 25 },
|
||||||
});
|
});
|
||||||
const list = listRes.data as UserNotification[];
|
const list = _normalizeNotificationList(listRes.data);
|
||||||
if (
|
if (
|
||||||
Array.isArray(list) &&
|
list.length > 0 &&
|
||||||
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
|
list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType))
|
||||||
) {
|
) {
|
||||||
window.dispatchEvent(new Event('features-changed'));
|
window.dispatchEvent(new Event('features-changed'));
|
||||||
|
|
|
||||||
161
src/hooks/usePrompt.tsx
Normal file
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
|
return false; // Don't show readonly fields in edit form
|
||||||
}
|
}
|
||||||
// Also filter out common non-editable fields
|
// Also filter out common non-editable fields
|
||||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -367,7 +367,7 @@ export function usePrompts() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Filter out ID fields and other auto-generated fields
|
// Filter out ID fields and other auto-generated fields
|
||||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -530,7 +530,7 @@ export function usePromptOperations() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pass all provided fields (supports partial inline updates like isSystem toggle)
|
// Pass all provided fields (supports partial inline updates like isSystem toggle)
|
||||||
const { id, mandateId, _createdBy, _createdAt, _modifiedAt, _permissions, ...requestBody } = updateData;
|
const { id, mandateId, sysCreatedBy, sysCreatedAt, sysModifiedAt, _permissions, ...requestBody } = updateData;
|
||||||
|
|
||||||
const updatedPrompt = await updatePromptApi(request, promptId, requestBody as UpdatePromptData);
|
const updatedPrompt = await updatePromptApi(request, promptId, requestBody as UpdatePromptData);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
|
||||||
.filter(attr => {
|
.filter(attr => {
|
||||||
if (attr.readonly === true || attr.editable === false) return false;
|
if (attr.readonly === true || attr.editable === false) return false;
|
||||||
if (attr.name === 'id') return false;
|
if (attr.name === 'id') return false;
|
||||||
const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
|
const nonEditable = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
|
||||||
return !nonEditable.includes(attr.name);
|
return !nonEditable.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -210,7 +210,7 @@ function _createRealEstateEntityHook<T extends { id: string }>(config: RealEstat
|
||||||
const generateCreateFieldsFromAttributes = useCallback(() => {
|
const generateCreateFieldsFromAttributes = useCallback(() => {
|
||||||
if (!attributes || attributes.length === 0) return [];
|
if (!attributes || attributes.length === 0) return [];
|
||||||
return attributes
|
return attributes
|
||||||
.filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
|
.filter(attr => !['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name))
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string';
|
||||||
let options: Array<{ value: string | number; label: string }> | undefined;
|
let options: Array<{ value: string | number; label: string }> | undefined;
|
||||||
|
|
|
||||||
|
|
@ -11,40 +11,64 @@ import {
|
||||||
fetchStoreFeatures,
|
fetchStoreFeatures,
|
||||||
activateStoreFeature,
|
activateStoreFeature,
|
||||||
deactivateStoreFeature,
|
deactivateStoreFeature,
|
||||||
|
fetchUserMandates,
|
||||||
|
fetchSubscriptionInfo,
|
||||||
type StoreFeature,
|
type StoreFeature,
|
||||||
|
type UserMandate,
|
||||||
|
type SubscriptionInfo,
|
||||||
} from '../api/storeApi';
|
} from '../api/storeApi';
|
||||||
import { useFeatureStore } from '../stores/featureStore';
|
import { useFeatureStore } from '../stores/featureStore';
|
||||||
|
|
||||||
interface UseStoreReturn {
|
interface UseStoreReturn {
|
||||||
features: StoreFeature[];
|
features: StoreFeature[];
|
||||||
|
mandates: UserMandate[];
|
||||||
|
subscriptionInfo: SubscriptionInfo | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
actionLoading: string | null;
|
actionLoading: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loadStore: () => Promise<void>;
|
loadStore: () => Promise<void>;
|
||||||
activate: (featureCode: string) => Promise<void>;
|
loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
|
||||||
deactivate: (featureCode: string) => Promise<void>;
|
activate: (featureCode: string, mandateId?: string) => Promise<void>;
|
||||||
|
deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStore(): UseStoreReturn {
|
export function useStore(): UseStoreReturn {
|
||||||
const [features, setFeatures] = useState<StoreFeature[]>([]);
|
const [features, setFeatures] = useState<StoreFeature[]>([]);
|
||||||
|
const [mandates, setMandates] = useState<UserMandate[]>([]);
|
||||||
|
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const featureStore = useFeatureStore();
|
const featureStore = useFeatureStore();
|
||||||
|
|
||||||
|
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
|
||||||
|
try {
|
||||||
|
const info = await fetchSubscriptionInfo(mandateId);
|
||||||
|
setSubscriptionInfo(info);
|
||||||
|
} catch {
|
||||||
|
// non-critical
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStore = useCallback(async () => {
|
const loadStore = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchStoreFeatures();
|
const [data, userMandates] = await Promise.all([
|
||||||
|
fetchStoreFeatures(),
|
||||||
|
fetchUserMandates(),
|
||||||
|
]);
|
||||||
setFeatures(data);
|
setFeatures(data);
|
||||||
|
setMandates(userMandates);
|
||||||
|
const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined;
|
||||||
|
await loadSubscriptionInfo(firstMandateId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to load store';
|
const msg = err instanceof Error ? err.message : 'Failed to load store';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [loadSubscriptionInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStore();
|
loadStore();
|
||||||
|
|
@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn {
|
||||||
await loadStore();
|
await loadStore();
|
||||||
}, [featureStore, loadStore]);
|
}, [featureStore, loadStore]);
|
||||||
|
|
||||||
const activate = useCallback(async (featureCode: string) => {
|
const activate = useCallback(async (featureCode: string, mandateId?: string) => {
|
||||||
setActionLoading(featureCode);
|
setActionLoading(featureCode);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await activateStoreFeature(featureCode);
|
await activateStoreFeature(featureCode, mandateId);
|
||||||
await _refreshAfterAction();
|
await _refreshAfterAction();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Activation failed';
|
const msg = err instanceof Error ? err.message : 'Activation failed';
|
||||||
|
|
@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn {
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction]);
|
||||||
|
|
||||||
const deactivate = useCallback(async (featureCode: string) => {
|
const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
|
||||||
setActionLoading(featureCode);
|
setActionLoading(featureCode);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await deactivateStoreFeature(featureCode);
|
await deactivateStoreFeature(featureCode, mandateId, instanceId);
|
||||||
await _refreshAfterAction();
|
await _refreshAfterAction();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
const msg = err instanceof Error ? err.message : 'Deactivation failed';
|
||||||
|
|
@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn {
|
||||||
}
|
}
|
||||||
}, [_refreshAfterAction]);
|
}, [_refreshAfterAction]);
|
||||||
|
|
||||||
return { features, loading, actionLoading, error, loadStore, activate, deactivate };
|
return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useStore;
|
export default useStore;
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
|
||||||
if (attr.name === 'id') {
|
if (attr.name === 'id') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nonEditableFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt'];
|
const nonEditableFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -284,7 +284,7 @@ function _createTrusteeEntityHook<T extends { id: string }>(config: TrusteeEntit
|
||||||
|
|
||||||
return attributes
|
return attributes
|
||||||
.filter(attr => {
|
.filter(attr => {
|
||||||
const systemFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId'];
|
const systemFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId'];
|
||||||
return !systemFields.includes(attr.name);
|
return !systemFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export function useTrusteeAccess() {
|
||||||
if (attr.readonly === true || attr.editable === false) {
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export function useTrusteeContracts() {
|
||||||
if (attr.readonly === true || attr.editable === false) {
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ export function useTrusteeDocuments() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// documentData is handled separately (binary upload)
|
// documentData is handled separately (binary upload)
|
||||||
const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
const nonEditableFields = ['id', 'documentData', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ export function useTrusteeOrganisations() {
|
||||||
if (attr.readonly === true || attr.editable === false) {
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ export function useTrusteePositionDocuments() {
|
||||||
if (attr.readonly === true || attr.editable === false) {
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export function useTrusteePositions() {
|
||||||
if (attr.readonly === true || attr.editable === false) {
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ export function useTrusteeRoles() {
|
||||||
if (attr.readonly === true || attr.editable === false) {
|
if (attr.readonly === true || attr.editable === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt'];
|
const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@ export function useOrgUsers() {
|
||||||
return false; // Don't show readonly fields in edit form
|
return false; // Don't show readonly fields in edit form
|
||||||
}
|
}
|
||||||
// Also filter out common non-editable fields
|
// Also filter out common non-editable fields
|
||||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
@ -560,7 +560,7 @@ export function useOrgUsers() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Filter out ID fields and other auto-generated fields
|
// Filter out ID fields and other auto-generated fields
|
||||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete', 'authenticationAuthority'];
|
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete', 'authenticationAuthority'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
|
||||||
return false; // Don't show readonly fields in edit form
|
return false; // Don't show readonly fields in edit form
|
||||||
}
|
}
|
||||||
// Also filter out common non-editable fields
|
// Also filter out common non-editable fields
|
||||||
const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete'];
|
const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete'];
|
||||||
return !nonEditableFields.includes(attr.name);
|
return !nonEditableFields.includes(attr.name);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, Navigate } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import useNavigation from '../hooks/useNavigation';
|
import useNavigation from '../hooks/useNavigation';
|
||||||
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
|
import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation';
|
||||||
import { getPageIcon } from '../config/pageRegistry';
|
import { getPageIcon } from '../config/pageRegistry';
|
||||||
import { FaArrowRight, FaBuilding } from 'react-icons/fa';
|
import { FaArrowRight, FaBuilding } from 'react-icons/fa';
|
||||||
|
import OnboardingAssistant from '../components/OnboardingAssistant';
|
||||||
import styles from './Dashboard.module.css';
|
import styles from './Dashboard.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalInstances === 0) {
|
|
||||||
return <Navigate to="/store" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboard}>
|
<div className={styles.dashboard}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>Übersicht</h1>
|
<h1>Übersicht</h1>
|
||||||
<p className={styles.subtitle}>
|
{totalInstances > 0 && (
|
||||||
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
|
<p className={styles.subtitle}>
|
||||||
</p>
|
Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<OnboardingAssistant />
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
{mandates
|
{mandates
|
||||||
.filter(mandate => mandate.features.some(f => f.instances.length > 0))
|
.filter(mandate => mandate.features.some(f => f.instances.length > 0))
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,50 @@
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ctaSection {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctaPrimary {
|
||||||
|
flex: 1;
|
||||||
|
height: 46px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--color-secondary);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctaPrimary:hover {
|
||||||
|
background-color: var(--color-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctaSecondary {
|
||||||
|
flex: 1;
|
||||||
|
height: 46px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctaSecondary:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa';
|
||||||
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
|
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
import { PENDING_INVITATION_KEY } from './InvitePage';
|
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
|
import OnboardingWizard from '../components/OnboardingWizard';
|
||||||
|
|
||||||
import styles from './Login.module.css';
|
import styles from './Login.module.css';
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ function Login() {
|
||||||
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
||||||
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
||||||
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
||||||
|
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
|
||||||
|
|
||||||
// Check for pending invitation
|
// Check for pending invitation
|
||||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||||
|
|
@ -84,6 +86,10 @@ function Login() {
|
||||||
console.log("Attempting Google login...");
|
console.log("Attempting Google login...");
|
||||||
const response = await loginWithGoogle();
|
const response = await loginWithGoogle();
|
||||||
console.log("Google login successful:", response);
|
console.log("Google login successful:", response);
|
||||||
|
if (response?.isNewUser) {
|
||||||
|
setShowOnboardingWizard(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleSuccessfulLogin();
|
handleSuccessfulLogin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Google login failed:", error);
|
console.error("Google login failed:", error);
|
||||||
|
|
@ -104,6 +110,21 @@ function Login() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showOnboardingWizard) {
|
||||||
|
return (
|
||||||
|
<OnboardingWizard
|
||||||
|
onComplete={() => {
|
||||||
|
setShowOnboardingWizard(false);
|
||||||
|
handleSuccessfulLogin();
|
||||||
|
}}
|
||||||
|
onDismiss={() => {
|
||||||
|
setShowOnboardingWizard(false);
|
||||||
|
handleSuccessfulLogin();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
|
@ -213,12 +234,15 @@ function Login() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={styles.registerLink}>
|
<div className={styles.registerLink}>
|
||||||
<span>Du hast noch keinen Konto?</span>
|
<span>Du hast noch kein Konto?</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.ctaSection}>
|
||||||
<button
|
<button
|
||||||
className={styles.textButton}
|
type="button"
|
||||||
onClick={() => navigate("/register", { state: location.state })}
|
className={styles.ctaPrimary}
|
||||||
|
onClick={() => navigate('/register', { state: location.state })}
|
||||||
>
|
>
|
||||||
Registrieren
|
Kostenlos registrieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ function Register() {
|
||||||
const { register, error: registerError, isLoading } = useRegister();
|
const { register, error: registerError, isLoading } = useRegister();
|
||||||
const { error: msalError } = useMsalRegister();
|
const { error: msalError } = useMsalRegister();
|
||||||
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
|
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
|
||||||
// Pre-fill from invitation if provided via location.state
|
|
||||||
const invitationUsername = (location.state as any)?.invitationUsername || '';
|
const invitationUsername = (location.state as any)?.invitationUsername || '';
|
||||||
const invitationEmail = (location.state as any)?.invitationEmail || '';
|
const invitationEmail = (location.state as any)?.invitationEmail || '';
|
||||||
const [formData, setFormData] = useState<RegisterFormData>({
|
const [formData, setFormData] = useState<RegisterFormData>({
|
||||||
|
|
@ -34,15 +33,11 @@ function Register() {
|
||||||
const [fullNameFocused, setFullNameFocused] = useState(false);
|
const [fullNameFocused, setFullNameFocused] = useState(false);
|
||||||
const [usernameHighlight, setUsernameHighlight] = useState(false);
|
const [usernameHighlight, setUsernameHighlight] = useState(false);
|
||||||
|
|
||||||
// Check for pending invitation
|
|
||||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||||
const hasPendingInvitation = !!pendingInvitationToken;
|
const hasPendingInvitation = !!pendingInvitationToken;
|
||||||
|
|
||||||
// Set page title and generate CSRF token
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "PowerOn AI Platform - Registrieren";
|
document.title = "PowerOn AI Platform - Registrieren";
|
||||||
|
|
||||||
// Generate CSRF token for new security implementation
|
|
||||||
generateAndStoreCSRFToken();
|
generateAndStoreCSRFToken();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -53,13 +48,12 @@ function Register() {
|
||||||
[name]: value
|
[name]: value
|
||||||
}));
|
}));
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
// Reset username highlight when user starts typing in username field
|
|
||||||
if (name === 'username') {
|
if (name === 'username') {
|
||||||
setUsernameHighlight(false);
|
setUsernameHighlight(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const _validateForm = (): boolean => {
|
||||||
if (!formData.username || !formData.email || !formData.fullName) {
|
if (!formData.username || !formData.email || !formData.fullName) {
|
||||||
setValidationError('Bitte füllen Sie alle Pflichtfelder aus.');
|
setValidationError('Bitte füllen Sie alle Pflichtfelder aus.');
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -76,16 +70,14 @@ function Register() {
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!_validateForm()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First check username availability
|
|
||||||
const availabilityResult = await checkAvailability(formData.username, 'local');
|
const availabilityResult = await checkAvailability(formData.username, 'local');
|
||||||
|
|
||||||
if (!availabilityResult.available) {
|
if (!availabilityResult.available) {
|
||||||
// Check if the error message is about username being taken
|
|
||||||
const errorMessage = availabilityResult.message || 'Username is not available';
|
const errorMessage = availabilityResult.message || 'Username is not available';
|
||||||
if (errorMessage === 'Username is already taken') {
|
if (errorMessage === 'Username is already taken') {
|
||||||
setValidationError('Benutzername ist bereits vergeben');
|
setValidationError('Benutzername ist bereits vergeben');
|
||||||
|
|
@ -96,25 +88,20 @@ function Register() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username is available, proceed with registration (no password - magic link flow)
|
await register({ ...formData, registrationType: 'personal' });
|
||||||
await register(formData);
|
|
||||||
|
|
||||||
// Build success message
|
|
||||||
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
|
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
|
||||||
if (hasPendingInvitation) {
|
if (hasPendingInvitation) {
|
||||||
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
|
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success message instead of immediate redirect
|
|
||||||
setSuccessMessage(message);
|
setSuccessMessage(message);
|
||||||
|
|
||||||
// Redirect to login page after delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/login', {
|
navigate('/login', {
|
||||||
state: {
|
state: {
|
||||||
registered: true,
|
registered: true,
|
||||||
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
|
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
|
||||||
// Pass along invitation state
|
|
||||||
...(location.state || {})
|
...(location.state || {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -124,8 +111,7 @@ function Register() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to safely get error message
|
const _getErrorMessage = () => {
|
||||||
const getErrorMessage = () => {
|
|
||||||
if (validationError) return validationError;
|
if (validationError) return validationError;
|
||||||
if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed';
|
if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed';
|
||||||
if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed';
|
if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed';
|
||||||
|
|
@ -146,7 +132,6 @@ function Register() {
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
<div className={styles.loginForm}>
|
<div className={styles.loginForm}>
|
||||||
{/* Pending invitation notice */}
|
|
||||||
{hasPendingInvitation && !successMessage && (
|
{hasPendingInvitation && !successMessage && (
|
||||||
<div className={styles.invitationNotice}>
|
<div className={styles.invitationNotice}>
|
||||||
<FaEnvelopeOpenText className={styles.invitationIcon} />
|
<FaEnvelopeOpenText className={styles.invitationIcon} />
|
||||||
|
|
@ -154,8 +139,8 @@ function Register() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{getErrorMessage() && (
|
{_getErrorMessage() && (
|
||||||
<div className={styles.error}>{getErrorMessage()}</div>
|
<div className={styles.error}>{_getErrorMessage()}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{successMessage && (
|
{successMessage && (
|
||||||
|
|
@ -221,7 +206,7 @@ function Register() {
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isLoading || isChecking}
|
disabled={isLoading || isChecking}
|
||||||
>
|
>
|
||||||
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : "Registrieren"}
|
{isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
/**
|
/**
|
||||||
* Settings Page
|
* Settings Page — User-level settings with tabs.
|
||||||
*
|
* Route: /settings
|
||||||
* Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
||||||
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
||||||
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||||
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||||
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import styles from './Settings.module.css';
|
import styles from './Settings.module.css';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TYPES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy';
|
||||||
|
|
||||||
|
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||||
|
{ key: 'profile', label: 'Profil' },
|
||||||
|
{ key: 'appearance', label: 'Darstellung' },
|
||||||
|
{ key: 'voice', label: 'Stimme & Sprache' },
|
||||||
|
{ key: 'neutralization', label: 'Neutralisierung (lokal)' },
|
||||||
|
{ key: 'privacy', label: 'Datenschutz' },
|
||||||
|
];
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// PROFILE EDIT MODAL
|
// PROFILE EDIT MODAL
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -27,39 +41,13 @@ interface ProfileEditModalProps {
|
||||||
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Define editable profile fields
|
|
||||||
const profileAttributes: AttributeDefinition[] = [
|
const profileAttributes: AttributeDefinition[] = [
|
||||||
{
|
{ name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' },
|
||||||
name: 'fullName',
|
{ name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' },
|
||||||
type: 'string',
|
{ name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] },
|
||||||
label: 'Vollständiger Name',
|
|
||||||
description: 'Ihr vollständiger Name',
|
|
||||||
required: false,
|
|
||||||
placeholder: 'Max Mustermann'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'email',
|
|
||||||
type: 'email',
|
|
||||||
label: 'E-Mail-Adresse',
|
|
||||||
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'name@example.com'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'language',
|
|
||||||
type: 'select',
|
|
||||||
label: 'Sprache',
|
|
||||||
description: 'Anzeigesprache der Anwendung',
|
|
||||||
required: true,
|
|
||||||
options: [
|
|
||||||
{ value: 'de', label: 'Deutsch' },
|
|
||||||
{ value: 'en', label: 'English' },
|
|
||||||
{ value: 'fr', label: 'Français' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSubmit = async (formData: any) => {
|
const handleSubmit = async (formData: any) => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -72,9 +60,9 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.modalOverlay} onClick={onClose}>
|
<div className={styles.modalOverlay} onClick={onClose}>
|
||||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||||
|
|
@ -84,21 +72,358 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalBody}>
|
<div className={styles.modalBody}>
|
||||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm attributes={profileAttributes} data={userData} mode="edit" onSubmit={handleSubmit} onCancel={onClose} submitButtonText={isSaving ? 'Speichern...' : 'Speichern'} cancelButtonText="Abbrechen" />
|
||||||
attributes={profileAttributes}
|
|
||||||
data={userData}
|
|
||||||
mode="edit"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onCancel={onClose}
|
|
||||||
submitButtonText={isSaving ? 'Speichern...' : 'Speichern'}
|
|
||||||
cancelButtonText="Abbrechen"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// VOICE SETTINGS TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface VoiceMapEntry { language: string; voiceName: string; }
|
||||||
|
|
||||||
|
const VoiceSettingsTab: React.FC = () => {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [sttLanguage, setSttLanguage] = useState('de-DE');
|
||||||
|
const [languages, setLanguages] = useState<any[]>([]);
|
||||||
|
const [voiceMap, setVoiceMap] = useState<VoiceMapEntry[]>([]);
|
||||||
|
|
||||||
|
const [addLanguage, setAddLanguage] = useState('de-DE');
|
||||||
|
const [addVoices, setAddVoices] = useState<any[]>([]);
|
||||||
|
const [addVoiceName, setAddVoiceName] = useState('');
|
||||||
|
const [loadingVoices, setLoadingVoices] = useState(false);
|
||||||
|
|
||||||
|
const _loadSettings = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [prefsData, languagesData] = await Promise.all([
|
||||||
|
request({ url: '/api/voice/preferences', method: 'get' }),
|
||||||
|
request({ url: '/api/voice/languages', method: 'get' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const langList = (languagesData as any)?.languages || [];
|
||||||
|
setLanguages(langList);
|
||||||
|
|
||||||
|
const prefs = prefsData as any;
|
||||||
|
setSttLanguage(prefs?.sttLanguage || 'de-DE');
|
||||||
|
|
||||||
|
const map: Record<string, any> = prefs?.ttsVoiceMap || {};
|
||||||
|
const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({
|
||||||
|
language: lang,
|
||||||
|
voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '',
|
||||||
|
}));
|
||||||
|
setVoiceMap(entries);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden der Voice-Einstellungen');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadSettings(); }, [_loadSettings]);
|
||||||
|
|
||||||
|
const _loadVoicesForLanguage = useCallback(async (lang: string) => {
|
||||||
|
setLoadingVoices(true);
|
||||||
|
try {
|
||||||
|
const result = await request({ url: '/api/voice/voices', method: 'get', params: { language: lang } });
|
||||||
|
setAddVoices((result as any)?.voices || []);
|
||||||
|
setAddVoiceName('');
|
||||||
|
} catch { setAddVoices([]); }
|
||||||
|
finally { setLoadingVoices(false); }
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]);
|
||||||
|
|
||||||
|
const _handleAddEntry = useCallback(() => {
|
||||||
|
if (!addLanguage) return;
|
||||||
|
const exists = voiceMap.some(e => e.language === addLanguage);
|
||||||
|
if (exists) {
|
||||||
|
setVoiceMap(prev => prev.map(e => e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e));
|
||||||
|
} else {
|
||||||
|
setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]);
|
||||||
|
}
|
||||||
|
setAddVoiceName('');
|
||||||
|
}, [addLanguage, addVoiceName, voiceMap]);
|
||||||
|
|
||||||
|
const _handleRemoveEntry = useCallback((lang: string) => {
|
||||||
|
setVoiceMap(prev => prev.filter(e => e.language !== lang));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleSave = useCallback(async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const mapObj: Record<string, any> = {};
|
||||||
|
voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; });
|
||||||
|
await request({
|
||||||
|
url: '/api/voice/preferences',
|
||||||
|
method: 'put',
|
||||||
|
data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj },
|
||||||
|
});
|
||||||
|
setSuccess('Einstellungen gespeichert');
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
await _loadSettings();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Speichern');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [request, voiceMap, sttLanguage, _loadSettings]);
|
||||||
|
|
||||||
|
const _handleTestVoice = useCallback(async (lang: string, voice: string) => {
|
||||||
|
setTesting(lang);
|
||||||
|
try {
|
||||||
|
const result: any = await request({
|
||||||
|
url: '/api/voice/test',
|
||||||
|
method: 'post',
|
||||||
|
data: { language: lang, voiceId: voice || undefined },
|
||||||
|
});
|
||||||
|
if (result?.success && result?.audio) {
|
||||||
|
const audio = new Audio(`data:audio/mp3;base64,${result.audio}`);
|
||||||
|
audio.play();
|
||||||
|
}
|
||||||
|
} catch { setError('Stimmtest fehlgeschlagen'); }
|
||||||
|
finally { setTesting(null); }
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const _getLanguageName = useCallback((code: string) => {
|
||||||
|
const found = languages.find((l: any) => (l.code || l) === code);
|
||||||
|
return found?.name || found?.code || code;
|
||||||
|
}, [languages]);
|
||||||
|
|
||||||
|
const _defaultLangs = [
|
||||||
|
{ code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' },
|
||||||
|
{ code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' },
|
||||||
|
{ code: 'es-ES', name: 'Espanol' },
|
||||||
|
];
|
||||||
|
const _displayLanguages = languages.length > 0 ? languages : _defaultLangs;
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Einstellungen werden geladen...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
|
{success && <div style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', color: '#16a34a', padding: '0.75rem 1rem', borderRadius: 6, marginBottom: '1rem', fontSize: '0.875rem' }}>{success}</div>}
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>STT-Sprache (Spracheingabe)</h2>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}>
|
||||||
|
<label className={styles.settingLabel}>Sprache fuer Spracherkennung</label>
|
||||||
|
<p className={styles.settingDescription}>Wird fuer die Sprache-zu-Text-Erkennung verwendet.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<select className={styles.select} value={sttLanguage} onChange={e => setSttLanguage(e.target.value)}>
|
||||||
|
{_displayLanguages.map((lang: any) => (
|
||||||
|
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>TTS-Stimmen (Sprachausgabe)</h2>
|
||||||
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
|
Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{voiceMap.length === 0 ? (
|
||||||
|
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
|
||||||
|
Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
|
<thead><tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}><th style={{ textAlign: 'left', padding: '0.5rem' }}>Sprache</th><th style={{ textAlign: 'left', padding: '0.5rem' }}>Stimme</th><th /><th /></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{voiceMap.map(entry => (
|
||||||
|
<tr key={entry.language} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||||
|
<td style={{ padding: '0.5rem' }}>{_getLanguageName(entry.language)}</td>
|
||||||
|
<td style={{ padding: '0.5rem' }}>{entry.voiceName || 'Standard'}</td>
|
||||||
|
<td style={{ padding: '0.5rem' }}>
|
||||||
|
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem' }} onClick={() => _handleTestVoice(entry.language, entry.voiceName)} disabled={testing === entry.language}>
|
||||||
|
{testing === entry.language ? '...' : 'Test'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.5rem' }}>
|
||||||
|
<button className={styles.button} style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }} onClick={() => _handleRemoveEntry(entry.language)}>Entfernen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Sprache</label>
|
||||||
|
<select className={styles.select} value={addLanguage} onChange={e => setAddLanguage(e.target.value)}>
|
||||||
|
{_displayLanguages.map((lang: any) => (
|
||||||
|
<option key={lang.code || lang} value={lang.code || lang}>{lang.name || lang.code || lang}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={styles.settingLabel} style={{ fontSize: '0.8rem' }}>Stimme</label>
|
||||||
|
<select className={styles.select} value={addVoiceName} onChange={e => setAddVoiceName(e.target.value)} disabled={loadingVoices}>
|
||||||
|
<option value="">Standard</option>
|
||||||
|
{addVoices.map((v: any) => (
|
||||||
|
<option key={v.name || v} value={v.name || v}>{v.displayName || v.name || v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className={styles.button} onClick={_handleAddEntry} style={{ padding: '0.5rem 1rem' }}>Zuweisen</button>
|
||||||
|
<button className={styles.button} onClick={() => _handleTestVoice(addLanguage, addVoiceName)} disabled={testing !== null} style={{ padding: '0.5rem 1rem' }}>
|
||||||
|
{testing === addLanguage ? '...' : 'Testen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button className={styles.button} onClick={_handleSave} disabled={saving} style={{ background: 'var(--primary-color, #2563eb)', color: '#fff', border: 'none', padding: '0.625rem 1.5rem', fontWeight: 600, borderRadius: 6 }}>
|
||||||
|
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NEUTRALIZATION MAPPINGS TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface NeutralizationMapping {
|
||||||
|
id: string;
|
||||||
|
originalText: string;
|
||||||
|
patternType: string;
|
||||||
|
fileId?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NeutralizationMappingsTab: React.FC = () => {
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result: any = await request({ url: '/api/local/neutralization-mappings', method: 'get' });
|
||||||
|
const items = (result?.mappings || []).map((m: any) => ({
|
||||||
|
id: m.id,
|
||||||
|
originalText: m.originalText || '',
|
||||||
|
patternType: m.patternType || '',
|
||||||
|
fileId: m.fileId,
|
||||||
|
featureInstanceId: m.featureInstanceId,
|
||||||
|
}));
|
||||||
|
setMappings(items);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Laden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => { _load(); }, [_load]);
|
||||||
|
|
||||||
|
const _handleDelete = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' });
|
||||||
|
setMappings(prev => prev.filter(m => m.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Fehler beim Loeschen');
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const _maskText = (text: string) => {
|
||||||
|
if (text.length <= 4) return '****';
|
||||||
|
return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: '1rem', color: '#888' }}>Mappings werden geladen...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Platzhalter-Mappings (lokal)</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
background: 'var(--surface-color, #eff6ff)',
|
||||||
|
border: '1px solid var(--border-color, #bfdbfe)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: 'var(--text-primary, #1e3a5f)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
|
||||||
|
<strong>Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“</strong> (nicht auf dieser
|
||||||
|
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
|
||||||
|
</div>
|
||||||
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
|
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle
|
||||||
|
geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber
|
||||||
|
den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings — hier einsehbar und loeschbar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{mappings.length === 0 ? (
|
||||||
|
<div style={{ padding: '0.75rem', background: 'var(--surface-color, #f9fafb)', borderRadius: 8, fontSize: '0.85rem', color: '#888' }}>
|
||||||
|
Keine Neutralisierungs-Mappings vorhanden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Platzhalter-ID</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Originaltext</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '0.5rem' }}>Typ</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mappings.map(m => (
|
||||||
|
<tr key={m.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||||
|
<td style={{ padding: '0.5rem', fontFamily: 'monospace', fontSize: '0.75rem' }}>{m.id.slice(0, 12)}...</td>
|
||||||
|
<td style={{ padding: '0.5rem' }}>{_maskText(m.originalText)}</td>
|
||||||
|
<td style={{ padding: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: '#f3f4f6' }}>
|
||||||
|
{m.patternType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
style={{ padding: '0.25rem 0.5rem', fontSize: '0.75rem', color: '#dc2626' }}
|
||||||
|
onClick={() => _handleDelete(m.id)}
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SETTINGS PAGE
|
// SETTINGS PAGE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -107,266 +432,142 @@ export const SettingsPage: React.FC = () => {
|
||||||
const { currentLanguage, setLanguage } = useLanguage();
|
const { currentLanguage, setLanguage } = useLanguage();
|
||||||
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
||||||
const { updateUser } = useUser();
|
const { updateUser } = useUser();
|
||||||
|
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>(
|
const [activeTab, setActiveTab] = useState<SettingsTab>('profile');
|
||||||
() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
|
const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light');
|
||||||
);
|
|
||||||
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
||||||
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
||||||
const [languageError, setLanguageError] = useState<string | null>(null);
|
const [languageError, setLanguageError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Handle theme change
|
|
||||||
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
localStorage.setItem('theme', newTheme);
|
localStorage.setItem('theme', newTheme);
|
||||||
|
if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); }
|
||||||
if (newTheme === 'dark') {
|
else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); }
|
||||||
document.documentElement.classList.add('dark-theme');
|
|
||||||
document.documentElement.classList.remove('light-theme');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.add('light-theme');
|
|
||||||
document.documentElement.classList.remove('dark-theme');
|
|
||||||
}
|
|
||||||
document.documentElement.setAttribute('data-theme', newTheme);
|
document.documentElement.setAttribute('data-theme', newTheme);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle language change - save to backend and update cache
|
|
||||||
const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => {
|
const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => {
|
||||||
if (!currentUser?.id || !currentUser?.username) return;
|
if (!currentUser?.id || !currentUser?.username) return;
|
||||||
|
|
||||||
setIsSavingLanguage(true);
|
setIsSavingLanguage(true);
|
||||||
setLanguageError(null);
|
setLanguageError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Build full user object for update (backend requires full User model)
|
await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: currentUser.email, fullName: currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
|
||||||
const userUpdateData = {
|
|
||||||
id: currentUser.id,
|
|
||||||
username: currentUser.username,
|
|
||||||
email: currentUser.email,
|
|
||||||
fullName: currentUser.fullName,
|
|
||||||
language: newLanguage,
|
|
||||||
enabled: currentUser.enabled ?? true,
|
|
||||||
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Save to backend
|
|
||||||
await updateUser(currentUser.id, userUpdateData);
|
|
||||||
|
|
||||||
// 3. Update sessionStorage cache
|
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
if (cachedUser) {
|
if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage });
|
||||||
setUserDataCache({ ...cachedUser, language: newLanguage });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Update UI language context
|
|
||||||
setLanguage(newLanguage);
|
setLanguage(newLanguage);
|
||||||
|
|
||||||
// 5. Dispatch event to notify other components
|
|
||||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||||
|
} catch { setLanguageError('Sprache konnte nicht gespeichert werden'); }
|
||||||
console.log('Language updated successfully to:', newLanguage);
|
finally { setIsSavingLanguage(false); }
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to update language:', err);
|
|
||||||
setLanguageError('Sprache konnte nicht gespeichert werden');
|
|
||||||
} finally {
|
|
||||||
setIsSavingLanguage(false);
|
|
||||||
}
|
|
||||||
}, [currentUser, updateUser, setLanguage]);
|
}, [currentUser, updateUser, setLanguage]);
|
||||||
|
|
||||||
// Handle profile save
|
|
||||||
const handleProfileSave = useCallback(async (formData: any) => {
|
const handleProfileSave = useCallback(async (formData: any) => {
|
||||||
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
|
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
|
||||||
|
|
||||||
// Get the new language (from form or current user)
|
|
||||||
const newLanguage = formData.language || currentUser.language || 'de';
|
const newLanguage = formData.language || currentUser.language || 'de';
|
||||||
|
const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' });
|
||||||
// Build full user object for update (backend requires full User model)
|
|
||||||
const userUpdateData = {
|
|
||||||
id: currentUser.id,
|
|
||||||
username: currentUser.username,
|
|
||||||
email: formData.email || currentUser.email,
|
|
||||||
fullName: formData.fullName || currentUser.fullName,
|
|
||||||
language: newLanguage,
|
|
||||||
enabled: currentUser.enabled ?? true,
|
|
||||||
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update user via API
|
|
||||||
const updatedUser = await updateUser(currentUser.id, userUpdateData);
|
|
||||||
|
|
||||||
// Update sessionStorage cache
|
|
||||||
const cachedUser = getUserDataCache();
|
const cachedUser = getUserDataCache();
|
||||||
if (cachedUser) {
|
if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage });
|
||||||
setUserDataCache({
|
if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr');
|
||||||
...cachedUser,
|
if (refetchUser) await refetchUser();
|
||||||
fullName: updatedUser.fullName || cachedUser.fullName,
|
|
||||||
email: updatedUser.email || cachedUser.email,
|
|
||||||
language: newLanguage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI language if changed
|
|
||||||
if (newLanguage !== currentLanguage) {
|
|
||||||
setLanguage(newLanguage as 'de' | 'en' | 'fr');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refetch user data
|
|
||||||
if (refetchUser) {
|
|
||||||
await refetchUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch event to notify other components (e.g., sidebar)
|
|
||||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||||
|
|
||||||
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
|
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<div className={styles.settings}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>Einstellungen</h1>
|
<h1>Einstellungen</h1>
|
||||||
<p className={styles.subtitle}>Persönliche Einstellungen und Präferenzen</p>
|
<p className={styles.subtitle}>Persoenliche Einstellungen und Praeferenzen</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border-color, #e0e0e0)', marginBottom: '1.5rem' }}>
|
||||||
|
{_TABS.map(tab => (
|
||||||
|
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
|
||||||
|
padding: '10px 20px', border: 'none', borderBottom: activeTab === tab.key ? '2px solid var(--primary-color, #2563eb)' : '2px solid transparent',
|
||||||
|
background: 'none', cursor: 'pointer', fontSize: 14, fontWeight: activeTab === tab.key ? 600 : 400,
|
||||||
|
color: activeTab === tab.key ? 'var(--primary-color, #2563eb)' : 'var(--text-secondary, #888)',
|
||||||
|
}}>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
{/* Darstellung */}
|
{activeTab === 'profile' && (
|
||||||
<section className={styles.section}>
|
<>
|
||||||
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Konto</h2>
|
||||||
<div className={styles.settingRow}>
|
<div className={styles.settingRow}>
|
||||||
<div className={styles.settingInfo}>
|
<div className={styles.settingInfo}>
|
||||||
<label className={styles.settingLabel}>Theme</label>
|
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||||
<p className={styles.settingDescription}>
|
<p className={styles.settingDescription}>Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.</p>
|
||||||
Wähle zwischen hellem und dunklem Design.
|
</div>
|
||||||
</p>
|
<div className={styles.settingControl}>
|
||||||
</div>
|
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>Profil oeffnen</button>
|
||||||
<div className={styles.settingControl}>
|
</div>
|
||||||
<div className={styles.themeToggle}>
|
</div>
|
||||||
<button
|
{currentUser && (
|
||||||
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`}
|
<div className={styles.userInfoCard}>
|
||||||
onClick={() => handleThemeChange('light')}
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Benutzername</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
|
||||||
>
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Name</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
|
||||||
Hell
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>E-Mail</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
|
||||||
</button>
|
</div>
|
||||||
<button
|
)}
|
||||||
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`}
|
</section>
|
||||||
onClick={() => handleThemeChange('dark')}
|
<section className={styles.section}>
|
||||||
>
|
<h2 className={styles.sectionTitle}>Ueber</h2>
|
||||||
Dunkel
|
<div className={styles.infoCard}>
|
||||||
</button>
|
<div className={styles.infoRow}><span className={styles.infoLabel}>Version</span><span className={styles.infoValue}>2.0.0</span></div>
|
||||||
|
<div className={styles.infoRow}><span className={styles.infoLabel}>Build</span><span className={styles.infoValue}>2026.03.23</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'appearance' && (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>Waehlen Sie zwischen hellem und dunklem Design.</p></div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<div className={styles.themeToggle}>
|
||||||
|
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>Hell</button>
|
||||||
|
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>Dunkel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>Anzeigesprache</label><p className={styles.settingDescription}>Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && <span className={styles.errorText}> {languageError}</span>}</p></div>
|
||||||
<div className={styles.settingRow}>
|
<div className={styles.settingControl}>
|
||||||
<div className={styles.settingInfo}>
|
<select className={styles.select} value={currentLanguage} onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')} disabled={isSavingLanguage}>
|
||||||
<label className={styles.settingLabel}>Sprache</label>
|
<option value="de">Deutsch</option><option value="en">English</option><option value="fr">Français</option>
|
||||||
<p className={styles.settingDescription}>
|
</select>
|
||||||
Wähle die Anzeigesprache der Anwendung.
|
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
||||||
{languageError && <span className={styles.errorText}> {languageError}</span>}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<select
|
|
||||||
className={styles.select}
|
|
||||||
value={currentLanguage}
|
|
||||||
onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')}
|
|
||||||
disabled={isSavingLanguage}
|
|
||||||
>
|
|
||||||
<option value="de">Deutsch</option>
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="fr">Français</option>
|
|
||||||
</select>
|
|
||||||
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Konto */}
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>Konto</h2>
|
|
||||||
|
|
||||||
<div className={styles.settingRow}>
|
|
||||||
<div className={styles.settingInfo}>
|
|
||||||
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
|
||||||
<p className={styles.settingDescription}>
|
|
||||||
Ändere deinen Namen und E-Mail-Adresse.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<button
|
|
||||||
className={styles.button}
|
|
||||||
onClick={async () => {
|
|
||||||
await refetchUser();
|
|
||||||
setIsProfileModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Profil öffnen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Current user info display */}
|
|
||||||
{currentUser && (
|
|
||||||
<div className={styles.userInfoCard}>
|
|
||||||
<div className={styles.userInfoRow}>
|
|
||||||
<span className={styles.userInfoLabel}>Benutzername</span>
|
|
||||||
<span className={styles.userInfoValue}>{currentUser.username}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.userInfoRow}>
|
|
||||||
<span className={styles.userInfoLabel}>Name</span>
|
|
||||||
<span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.userInfoRow}>
|
|
||||||
<span className={styles.userInfoLabel}>E-Mail</span>
|
|
||||||
<span className={styles.userInfoValue}>{currentUser.email || '-'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
</section>
|
)}
|
||||||
|
|
||||||
{/* Datenschutz */}
|
{activeTab === 'voice' && <VoiceSettingsTab />}
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
|
||||||
|
|
||||||
<div className={styles.settingRow}>
|
{activeTab === 'privacy' && (
|
||||||
<div className={styles.settingInfo}>
|
<section className={styles.section}>
|
||||||
<label className={styles.settingLabel}>GDPR / Privacy</label>
|
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
||||||
<p className={styles.settingDescription}>
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
Data export, portability and account deletion.
|
Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich
|
||||||
</p>
|
nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export,
|
||||||
|
Löschung) finden Sie unter GDPR.
|
||||||
|
</p>
|
||||||
|
<div className={styles.settingRow}>
|
||||||
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>GDPR / Privacy</label><p className={styles.settingDescription}>Datenexport, Portabilität und Kontolöschung.</p></div>
|
||||||
|
<div className={styles.settingControl}><Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">GDPR öffnen</Link></div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.settingControl}>
|
</section>
|
||||||
<Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">
|
)}
|
||||||
Open GDPR page
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h2 className={styles.sectionTitle}>Über</h2>
|
|
||||||
|
|
||||||
<div className={styles.infoCard}>
|
|
||||||
<div className={styles.infoRow}>
|
|
||||||
<span className={styles.infoLabel}>Version</span>
|
|
||||||
<span className={styles.infoValue}>2.0.0</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.infoRow}>
|
|
||||||
<span className={styles.infoLabel}>Build</span>
|
|
||||||
<span className={styles.infoValue}>2026.01.20</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Profile Edit Modal */}
|
<ProfileEditModal isOpen={isProfileModalOpen} onClose={() => setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
|
||||||
<ProfileEditModal
|
|
||||||
isOpen={isProfileModalOpen}
|
|
||||||
onClose={() => setIsProfileModalOpen(false)}
|
|
||||||
userData={currentUser}
|
|
||||||
onSave={handleProfileSave}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,52 @@
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subscription Banner */
|
||||||
|
.subscriptionBanner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: var(--info-bg, #eff6ff);
|
||||||
|
border: 1px solid var(--info-border, #bfdbfe);
|
||||||
|
color: var(--info-color, #1e40af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerSeparator::before {
|
||||||
|
content: '|';
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mandate Select */
|
||||||
|
.mandateSelect {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
background: var(--surface-color, #ffffff);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mandateSelect:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mandateHint {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Grid */
|
/* Grid */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -120,8 +166,54 @@
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Instance List */
|
||||||
|
.instanceList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instanceInfo {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deactivateButtonSmall {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deactivateButtonSmall:hover:not(:disabled) {
|
||||||
|
border-color: var(--error-color, #dc2626);
|
||||||
|
color: var(--error-color, #dc2626);
|
||||||
|
background: var(--error-bg, #fef2f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deactivateButtonSmall:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
.cardActions {
|
.cardActions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
@ -243,17 +335,35 @@
|
||||||
border-top-color: var(--border-dark, #333);
|
border-top-color: var(--border-dark, #333);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .deactivateButton {
|
:global(.dark-theme) .deactivateButton,
|
||||||
|
:global(.dark-theme) .deactivateButtonSmall {
|
||||||
border-color: var(--border-dark, #444);
|
border-color: var(--border-dark, #444);
|
||||||
color: var(--text-secondary-dark, #aaa);
|
color: var(--text-secondary-dark, #aaa);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .deactivateButton:hover:not(:disabled) {
|
:global(.dark-theme) .deactivateButton:hover:not(:disabled),
|
||||||
|
:global(.dark-theme) .deactivateButtonSmall:hover:not(:disabled) {
|
||||||
border-color: var(--error-color-dark, #f87171);
|
border-color: var(--error-color-dark, #f87171);
|
||||||
color: var(--error-color-dark, #f87171);
|
color: var(--error-color-dark, #f87171);
|
||||||
background: rgba(248, 113, 113, 0.1);
|
background: rgba(248, 113, 113, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .subscriptionBanner {
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
border-color: rgba(37, 99, 235, 0.25);
|
||||||
|
color: var(--primary-light, #93bbfc);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .mandateSelect {
|
||||||
|
background: var(--surface-dark, #1a1a1a);
|
||||||
|
border-color: var(--border-dark, #444);
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .mandateHint {
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .error {
|
:global(.dark-theme) .error {
|
||||||
background: var(--error-bg-dark, #450a0a);
|
background: var(--error-bg-dark, #450a0a);
|
||||||
border-color: var(--error-border-dark, #991b1b);
|
border-color: var(--error-border-dark, #991b1b);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Store Page
|
* Feature Store -- Users activate feature instances in their own mandates.
|
||||||
*
|
* Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance
|
||||||
* Feature Store where users can self-activate features in the root mandate.
|
* in the selected mandate. Explicit mandate selection required.
|
||||||
* Uses the Shared Instance Pattern -- each feature has one shared instance,
|
|
||||||
* and users get their own FeatureAccess + user-role upon activation.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature } from '../api/storeApi';
|
import type { StoreFeature, UserMandate } from '../api/storeApi';
|
||||||
|
import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize';
|
||||||
import styles from './Store.module.css';
|
import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
|
@ -62,23 +61,27 @@ function _getDescription(featureCode: string, lang: string): string {
|
||||||
interface FeatureCardProps {
|
interface FeatureCardProps {
|
||||||
feature: StoreFeature;
|
feature: StoreFeature;
|
||||||
language: string;
|
language: string;
|
||||||
|
mandates: UserMandate[];
|
||||||
actionLoading: string | null;
|
actionLoading: string | null;
|
||||||
onActivate: (code: string) => void;
|
onActivate: (code: string, mandateId?: string) => void;
|
||||||
onDeactivate: (code: string) => void;
|
onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeatureCard: React.FC<FeatureCardProps> = ({
|
const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
feature,
|
feature,
|
||||||
language,
|
language,
|
||||||
|
mandates,
|
||||||
actionLoading,
|
actionLoading,
|
||||||
onActivate,
|
onActivate,
|
||||||
onDeactivate,
|
onDeactivate,
|
||||||
}) => {
|
}) => {
|
||||||
const isProcessing = actionLoading === feature.featureCode;
|
const isProcessing = actionLoading === feature.featureCode;
|
||||||
const icon = FEATURE_ICONS[feature.featureCode];
|
const icon = FEATURE_ICONS[feature.featureCode];
|
||||||
|
const activeInstances = feature.instances.filter(inst => inst.isActive);
|
||||||
|
const hasActive = activeInstances.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}>
|
<div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
{icon && <span className={styles.cardIcon}>{icon}</span>}
|
{icon && <span className={styles.cardIcon}>{icon}</span>}
|
||||||
<h3 className={styles.cardTitle}>
|
<h3 className={styles.cardTitle}>
|
||||||
|
|
@ -92,37 +95,56 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{activeInstances.length > 0 && (
|
||||||
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}>
|
<div className={styles.instanceList}>
|
||||||
<span className={styles.statusDot} />
|
{activeInstances.map((inst) => (
|
||||||
{feature.isActive
|
<div key={inst.instanceId} className={styles.instanceRow}>
|
||||||
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active')
|
<div className={styles.instanceInfo}>
|
||||||
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')}
|
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
|
||||||
</span>
|
<span className={styles.statusDot} />
|
||||||
</div>
|
{inst.mandateName || inst.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.deactivateButtonSmall}
|
||||||
|
onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing
|
||||||
|
? '...'
|
||||||
|
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeInstances.length === 0 && (
|
||||||
|
<div>
|
||||||
|
<span className={`${styles.statusBadge} ${styles.statusInactive}`}>
|
||||||
|
<span className={styles.statusDot} />
|
||||||
|
{language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
{feature.isActive ? (
|
{feature.canActivate && mandates.map((m) => (
|
||||||
<button
|
<button
|
||||||
className={styles.deactivateButton}
|
key={m.id}
|
||||||
onClick={() => onDeactivate(feature.featureCode)}
|
className={styles.activateButton}
|
||||||
|
onClick={() => onActivate(feature.featureCode, m.id)}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
|
||||||
{isProcessing
|
|
||||||
? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...')
|
|
||||||
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className={styles.activateButton}
|
|
||||||
onClick={() => onActivate(feature.featureCode)}
|
|
||||||
disabled={isProcessing || !feature.canActivate}
|
|
||||||
>
|
>
|
||||||
{isProcessing
|
{isProcessing
|
||||||
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
|
||||||
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
|
: (language === 'de'
|
||||||
|
? `Aktivieren fuer ${m.label || m.name}`
|
||||||
|
: language === 'fr'
|
||||||
|
? `Activer pour ${m.label || m.name}`
|
||||||
|
: `Activate for ${m.label || m.name}`)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -130,7 +152,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||||
|
|
||||||
const StorePage: React.FC = () => {
|
const StorePage: React.FC = () => {
|
||||||
const { currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
const { features, loading, actionLoading, error, activate, deactivate } = useStore();
|
const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.store}>
|
<div className={styles.store}>
|
||||||
|
|
@ -145,6 +167,33 @@ const StorePage: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{subscriptionInfo && subscriptionInfo.plan && (
|
||||||
|
<div className={styles.subscriptionBanner}>
|
||||||
|
<span>Plan: <strong>{subscriptionInfo.plan}</strong></span>
|
||||||
|
{subscriptionInfo.maxFeatureInstances != null && (
|
||||||
|
<span className={styles.bannerSeparator}>
|
||||||
|
{currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subscriptionInfo.maxDataVolumeMB != null && (
|
||||||
|
<span className={styles.bannerSeparator}>
|
||||||
|
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '}
|
||||||
|
{formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && (
|
||||||
|
<span className={styles.bannerSeparator}>
|
||||||
|
{currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
|
||||||
|
<span className={styles.bannerSeparator}>
|
||||||
|
{currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -164,6 +213,7 @@ const StorePage: React.FC = () => {
|
||||||
key={feature.featureCode}
|
key={feature.featureCode}
|
||||||
feature={feature}
|
feature={feature}
|
||||||
language={currentLanguage}
|
language={currentLanguage}
|
||||||
|
mandates={mandates}
|
||||||
actionLoading={actionLoading}
|
actionLoading={actionLoading}
|
||||||
onActivate={activate}
|
onActivate={activate}
|
||||||
onDeactivate={deactivate}
|
onDeactivate={deactivate}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
splitMandateAndBillingFromForm,
|
splitMandateAndBillingFromForm,
|
||||||
} from '../../utils/mandateBillingFormMerge';
|
} from '../../utils/mandateBillingFormMerge';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
||||||
|
|
@ -23,6 +24,7 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showWarning, showSuccess } = useToast();
|
const { showWarning, showSuccess } = useToast();
|
||||||
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const {
|
const {
|
||||||
mandates,
|
mandates,
|
||||||
columns,
|
columns,
|
||||||
|
|
@ -111,11 +113,18 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
setEditingBillingWarning(null);
|
setEditingBillingWarning(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete (confirmation handled by DeleteActionButton)
|
|
||||||
// System mandates (isSystem=true) are protected from deletion
|
|
||||||
const handleDeleteMandate = async (mandate: Mandate) => {
|
const handleDeleteMandate = async (mandate: Mandate) => {
|
||||||
if (mandate.isSystem) {
|
if (mandate.isSystem) {
|
||||||
return; // Safety guard - should not be reachable due to disabled button
|
return;
|
||||||
|
}
|
||||||
|
const entered = await prompt(
|
||||||
|
`Um den Mandanten "${mandate.name}" unwiderruflich zu löschen, geben Sie den Namen ein:`,
|
||||||
|
{ title: 'Mandant löschen', confirmLabel: 'Löschen', variant: 'danger', placeholder: mandate.name },
|
||||||
|
);
|
||||||
|
if (entered === null) return;
|
||||||
|
if (entered !== mandate.name) {
|
||||||
|
showWarning('Löschung abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await handleDelete(mandate.id);
|
await handleDelete(mandate.id);
|
||||||
};
|
};
|
||||||
|
|
@ -267,6 +276,8 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PromptDialog />
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingFormData && (
|
{editingFormData && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,7 @@ export const AdminMandateWizardPage: React.FC = () => {
|
||||||
if (billingSaved) {
|
if (billingSaved) {
|
||||||
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
|
showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert');
|
||||||
}
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('features-changed'));
|
||||||
await loadMandates();
|
await loadMandates();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const e = err as { response?: { data?: { detail?: string } }; message?: string };
|
const e = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes for edit modal
|
// Form attributes for edit modal
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked'];
|
const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked'];
|
||||||
return (attributes || [])
|
return (attributes || [])
|
||||||
.filter(attr => !excludedFields.includes(attr.name));
|
.filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
@ -244,7 +244,9 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Verbindungen</h1>
|
<h1 className={styles.pageTitle}>Verbindungen</h1>
|
||||||
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten (Google, Microsoft, ClickUp)</p>
|
<p className={styles.pageSubtitle}>
|
||||||
|
Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { FileNode } from '../../components/FolderTree/FolderTree';
|
||||||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||||
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
interface UserFile {
|
interface UserFile {
|
||||||
|
|
@ -31,6 +32,7 @@ interface UserFile {
|
||||||
export const FilesPage: React.FC = () => {
|
export const FilesPage: React.FC = () => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -142,7 +144,7 @@ export const FilesPage: React.FC = () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
cols.push({
|
cols.push({
|
||||||
key: '_createdBy',
|
key: 'sysCreatedBy',
|
||||||
label: 'Created By',
|
label: 'Created By',
|
||||||
type: 'text' as any,
|
type: 'text' as any,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
|
@ -223,11 +225,11 @@ export const FilesPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleNewFolder = useCallback(async () => {
|
const _handleNewFolder = useCallback(async () => {
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptInput('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) {
|
if (name?.trim()) {
|
||||||
await handleCreateFolder(name.trim(), selectedFolderId);
|
await handleCreateFolder(name.trim(), selectedFolderId);
|
||||||
}
|
}
|
||||||
}, [handleCreateFolder, selectedFolderId]);
|
}, [handleCreateFolder, selectedFolderId, promptInput]);
|
||||||
|
|
||||||
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
||||||
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
||||||
|
|
@ -289,7 +291,7 @@ export const FilesPage: React.FC = () => {
|
||||||
}, [selectedFolderId, _tableRefetch]);
|
}, [selectedFolderId, _tableRefetch]);
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
|
const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source'];
|
||||||
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
// Generate columns from attributes - exclude ID fields from display
|
// Generate columns from attributes - exclude ID fields from display
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
// Fields to hide in table view
|
// Fields to hide in table view
|
||||||
const hiddenColumns = ['id', 'mandateId', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
|
const hiddenColumns = ['id', 'mandateId', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions'];
|
||||||
|
|
||||||
const cols = (attributes || [])
|
const cols = (attributes || [])
|
||||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||||
|
|
@ -71,9 +71,9 @@ export const PromptsPage: React.FC = () => {
|
||||||
fkDisplayField: (attr as any).fkDisplayField,
|
fkDisplayField: (attr as any).fkDisplayField,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Add _createdBy column with FK resolution to show username
|
// Add sysCreatedBy column with FK resolution to show username
|
||||||
cols.push({
|
cols.push({
|
||||||
key: '_createdBy',
|
key: 'sysCreatedBy',
|
||||||
label: 'Created By',
|
label: 'Created By',
|
||||||
type: 'text' as any,
|
type: 'text' as any,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
|
@ -148,7 +148,7 @@ export const PromptsPage: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes for create/edit modal
|
// Form attributes for create/edit modal
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'isSystem', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions'];
|
const excludedFields = ['id', 'mandateId', 'isSystem', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions'];
|
||||||
return (attributes || [])
|
return (attributes || [])
|
||||||
.filter(attr => !excludedFields.includes(attr.name));
|
.filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.billingModel {
|
.mandateSubtitle {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--text-secondary, #888);
|
||||||
background: var(--bg-secondary, #2a2a2a);
|
background: var(--bg-secondary, #2a2a2a);
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,11 @@ interface SettingsEditorProps {
|
||||||
|
|
||||||
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loading }) => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'],
|
|
||||||
defaultUserCredit: Number(settings?.defaultUserCredit ?? 0),
|
|
||||||
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10),
|
||||||
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
notifyOnWarning: settings?.notifyOnWarning ?? true,
|
||||||
|
autoRechargeEnabled: settings?.autoRechargeEnabled ?? false,
|
||||||
|
rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10),
|
||||||
|
rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3),
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
@ -96,10 +97,11 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
setFormData({
|
setFormData({
|
||||||
billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE',
|
|
||||||
defaultUserCredit: Number(settings.defaultUserCredit ?? 0),
|
|
||||||
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10),
|
||||||
notifyOnWarning: settings.notifyOnWarning ?? true,
|
notifyOnWarning: settings.notifyOnWarning ?? true,
|
||||||
|
autoRechargeEnabled: settings.autoRechargeEnabled ?? false,
|
||||||
|
rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10),
|
||||||
|
rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
@ -130,32 +132,6 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className={styles.formRow}>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label>Abrechnungsmodell</label>
|
|
||||||
<select
|
|
||||||
className={styles.select}
|
|
||||||
value={formData.billingModel}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, billingModel: e.target.value as BillingSettings['billingModel'] }))}
|
|
||||||
>
|
|
||||||
<option value="PREPAY_MANDATE">Prepaid (Mandant)</option>
|
|
||||||
<option value="PREPAY_USER">Prepaid (Benutzer)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label>Standard-Guthaben (CHF)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={styles.input}
|
|
||||||
value={formData.defaultUserCredit}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))}
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>Warnschwelle (%)</label>
|
<label>Warnschwelle (%)</label>
|
||||||
|
|
@ -184,6 +160,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup} style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.autoRechargeEnabled}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, autoRechargeEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Auto-Nachladung (AI-Guthaben bei niedrigem Stand)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{formData.autoRechargeEnabled && (
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>Betrag pro Nachladung (CHF)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.rechargeAmountCHF}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData(prev => ({ ...prev, rechargeAmountCHF: Number(e.target.value) }))
|
||||||
|
}
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>Max. Nachladungen / Monat</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.input}
|
||||||
|
value={formData.rechargeMaxPerMonth}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData(prev => ({ ...prev, rechargeMaxPerMonth: Math.max(0, Math.floor(Number(e.target.value))) }))
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -202,28 +221,15 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface CreditAdderProps {
|
interface CreditAdderProps {
|
||||||
settings: BillingSettings | null;
|
|
||||||
accounts: AccountSummary[];
|
|
||||||
users: MandateUserSummary[];
|
|
||||||
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
|
onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
const CreditAdder: React.FC<CreditAdderProps> = ({ onAddCredit }) => {
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
|
||||||
const [amount, setAmount] = useState<string>('');
|
const [amount, setAmount] = useState<string>('');
|
||||||
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
const isPrepayUser = settings?.billingModel === 'PREPAY_USER';
|
|
||||||
|
|
||||||
const accountsByUserId = accounts
|
|
||||||
.filter(acc => acc.accountType === 'USER')
|
|
||||||
.reduce((map, acc) => {
|
|
||||||
if (acc.userId) map[acc.userId] = acc;
|
|
||||||
return map;
|
|
||||||
}, {} as Record<string, AccountSummary>);
|
|
||||||
|
|
||||||
const _handleSubmit = async (e: React.FormEvent) => {
|
const _handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const numAmount = parseFloat(amount);
|
const numAmount = parseFloat(amount);
|
||||||
|
|
@ -236,7 +242,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
await onAddCredit(undefined, numAmount, description);
|
||||||
const label = numAmount > 0
|
const label = numAmount > 0
|
||||||
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
||||||
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
||||||
|
|
@ -260,31 +266,6 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={_handleSubmit}>
|
<form onSubmit={_handleSubmit}>
|
||||||
{isPrepayUser && (
|
|
||||||
<div className={styles.formRow}>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label>Benutzer</label>
|
|
||||||
<select
|
|
||||||
className={styles.select}
|
|
||||||
value={selectedUserId}
|
|
||||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">-- Benutzer wählen --</option>
|
|
||||||
{users.map((user) => {
|
|
||||||
const account = accountsByUserId[user.id];
|
|
||||||
const balanceInfo = account ? ` (${_formatCurrency(account.balance)})` : ' (kein Konto)';
|
|
||||||
return (
|
|
||||||
<option key={user.id} value={user.id}>
|
|
||||||
{user.displayName || user.username || user.id}{balanceInfo}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>Betrag (CHF)</label>
|
<label>Betrag (CHF)</label>
|
||||||
|
|
@ -313,7 +294,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
disabled={saving || !amount}
|
||||||
>
|
>
|
||||||
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -328,11 +309,12 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
||||||
|
|
||||||
interface AccountsOverviewProps {
|
interface AccountsOverviewProps {
|
||||||
accounts: AccountSummary[];
|
accounts: AccountSummary[];
|
||||||
users: MandateUserSummary[];
|
/** Kept for call-site compatibility; only mandate pool accounts are shown. */
|
||||||
|
users?: MandateUserSummary[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, loading }) => {
|
const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, loading }) => {
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat('de-CH', {
|
return new Intl.NumberFormat('de-CH', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|
@ -340,19 +322,8 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a lookup map: userId -> display name
|
const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]);
|
||||||
const _userNameMap = useMemo(() => {
|
|
||||||
const map = new Map<string, string>();
|
|
||||||
for (const user of users) {
|
|
||||||
const displayName = user.displayName
|
|
||||||
|| [user.firstName, user.lastName].filter(Boolean).join(' ')
|
|
||||||
|| user.username
|
|
||||||
|| user.id;
|
|
||||||
map.set(user.id, displayName);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [users]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
|
return <div className={styles.loadingPlaceholder}>Lade Konten...</div>;
|
||||||
}
|
}
|
||||||
|
|
@ -360,16 +331,19 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (poolAccounts.length === 0) {
|
||||||
|
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminSection}>
|
<div className={styles.adminSection}>
|
||||||
<h3>Konten</h3>
|
<h3>Konten</h3>
|
||||||
<div className={styles.accountsGrid}>
|
<div className={styles.accountsGrid}>
|
||||||
{accounts.map((account) => (
|
{poolAccounts.map((account) => (
|
||||||
<div key={account.id} className={styles.accountCard}>
|
<div key={account.id} className={styles.accountCard}>
|
||||||
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
|
<h4>Mandanten-Konto</h4>
|
||||||
<div className={styles.accountInfo}>
|
<div className={styles.accountInfo}>
|
||||||
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
|
||||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||||
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -782,9 +756,6 @@ export const BillingAdmin: React.FC = () => {
|
||||||
<>
|
<>
|
||||||
{isSysAdmin && (
|
{isSysAdmin && (
|
||||||
<CreditAdder
|
<CreditAdder
|
||||||
settings={settings}
|
|
||||||
accounts={accounts}
|
|
||||||
users={users}
|
|
||||||
onAddCredit={_handleAddCredit}
|
onAddCredit={_handleAddCredit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBillingModelLabel = (model: string) => {
|
|
||||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
|
||||||
return 'Prepaid (Mandant)';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
|
className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}
|
||||||
|
|
@ -38,7 +33,6 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onClick }) => {
|
||||||
>
|
>
|
||||||
<div className={styles.balanceHeader}>
|
<div className={styles.balanceHeader}>
|
||||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||||
<span className={styles.billingModel}>{getBillingModelLabel(balance.billingModel)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.balanceAmount}>
|
<div className={styles.balanceAmount}>
|
||||||
{formatCurrency(balance.balance)}
|
{formatCurrency(balance.balance)}
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
|
||||||
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
import { useBilling, type BillingBalance } from '../../hooks/useBilling';
|
||||||
import { createCheckoutSession, UserTransaction } from '../../api/billingApi';
|
import { UserTransaction } from '../../api/billingApi';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500];
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HELPER: Currency formatter
|
// HELPER: Currency formatter
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -46,34 +43,51 @@ interface ViewStatistics {
|
||||||
timeSeries: Array<{ date: string; cost: number; count: number }>;
|
timeSeries: Array<{ date: string; cost: number; count: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DataVolumeInfo {
|
||||||
|
mandateId: string;
|
||||||
|
mandateName: string;
|
||||||
|
usedMB: number;
|
||||||
|
filesMB: number;
|
||||||
|
ragIndexMB: number;
|
||||||
|
maxDataVolumeMB: number | null;
|
||||||
|
percentUsed: number | null;
|
||||||
|
warning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// BALANCE CARD COMPONENT
|
// BALANCE CARD COMPONENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface BalanceCardProps {
|
interface BalanceCardProps {
|
||||||
balance: BillingBalance;
|
balance: BillingBalance;
|
||||||
onCheckout?: (mandateId: string, amount: number) => void;
|
onOpenMandateAdmin?: (mandateId: string) => void;
|
||||||
checkoutLoading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkoutLoading }) => {
|
const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onOpenMandateAdmin }) => {
|
||||||
const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]);
|
|
||||||
const [showCheckout, setShowCheckout] = useState(false);
|
|
||||||
|
|
||||||
const _getBillingModelLabel = (model: string) => {
|
|
||||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
|
||||||
return 'Prepaid (Mandant)';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing.
|
|
||||||
const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER';
|
|
||||||
const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
<div className={`${styles.balanceCard} ${balance.isWarning ? styles.warning : ''}`}>
|
||||||
<div className={styles.balanceHeader}>
|
<div className={styles.balanceHeader}>
|
||||||
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
{onOpenMandateAdmin ? (
|
||||||
<span className={styles.billingModel}>{_getBillingModelLabel(balance.billingModel)}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.mandateName}
|
||||||
|
onClick={() => onOpenMandateAdmin(balance.mandateId)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
font: 'inherit',
|
||||||
|
color: 'inherit',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{balance.mandateName}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<h3 className={styles.mandateName}>{balance.mandateName}</h3>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.balanceAmount}>
|
<div className={styles.balanceAmount}>
|
||||||
{_formatCurrency(balance.balance)}
|
{_formatCurrency(balance.balance)}
|
||||||
|
|
@ -83,60 +97,17 @@ const BalanceCard: React.FC<BalanceCardProps> = ({ balance, onCheckout, checkout
|
||||||
Niedriges Guthaben
|
Niedriges Guthaben
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isMandatePrepaidPool && (
|
<p
|
||||||
<p
|
style={{
|
||||||
style={{
|
marginTop: '12px',
|
||||||
marginTop: '12px',
|
fontSize: '13px',
|
||||||
fontSize: '13px',
|
lineHeight: 1.45,
|
||||||
lineHeight: 1.45,
|
opacity: 0.75,
|
||||||
opacity: 0.75,
|
marginBottom: 0,
|
||||||
marginBottom: 0,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
||||||
Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing).
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{canStripeTopUpHere && onCheckout && (
|
|
||||||
<div style={{ marginTop: '12px' }}>
|
|
||||||
{!showCheckout ? (
|
|
||||||
<button
|
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
|
||||||
style={{ width: '100%', fontSize: '13px', padding: '6px 12px' }}
|
|
||||||
onClick={() => setShowCheckout(true)}
|
|
||||||
>
|
|
||||||
Budget laden mit Kreditkarte
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
|
||||||
<select
|
|
||||||
className={styles.select}
|
|
||||||
value={selectedAmount}
|
|
||||||
onChange={(e) => setSelectedAmount(Number(e.target.value))}
|
|
||||||
style={{ flex: 1, fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
{STRIPE_AMOUNT_PRESETS.map((preset) => (
|
|
||||||
<option key={preset} value={preset}>{preset} CHF</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
|
||||||
style={{ fontSize: '13px', padding: '6px 12px', whiteSpace: 'nowrap' }}
|
|
||||||
disabled={checkoutLoading}
|
|
||||||
onClick={() => onCheckout(balance.mandateId, selectedAmount)}
|
|
||||||
>
|
|
||||||
{checkoutLoading ? 'Laden...' : 'Zahlen'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`${styles.button} ${styles.buttonSecondary || ''}`}
|
|
||||||
style={{ fontSize: '13px', padding: '6px 12px' }}
|
|
||||||
onClick={() => setShowCheckout(false)}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -329,9 +300,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] {
|
||||||
export const BillingDataView: React.FC = () => {
|
export const BillingDataView: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { request } = useApiRequest();
|
const navigate = useNavigate();
|
||||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
|
||||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
const _openMandateBillingAdmin = useCallback((mandateId: string) => {
|
||||||
|
navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Scope filter: 'personal' | 'all' | mandateId
|
// Scope filter: 'personal' | 'all' | mandateId
|
||||||
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
const [selectedScope, setSelectedScope] = useState<string>('personal');
|
||||||
|
|
@ -399,58 +373,20 @@ export const BillingDataView: React.FC = () => {
|
||||||
setCheckoutMessage(null);
|
setCheckoutMessage(null);
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
const _handleCheckout = useCallback(async (mandateId: string, amount: number) => {
|
|
||||||
setCheckoutLoading(true);
|
|
||||||
setCheckoutMessage(null);
|
|
||||||
try {
|
|
||||||
const currentUser = getUserDataCache();
|
|
||||||
const currentUrl = new URL(window.location.href);
|
|
||||||
currentUrl.searchParams.delete('success');
|
|
||||||
currentUrl.searchParams.delete('canceled');
|
|
||||||
currentUrl.searchParams.delete('session_id');
|
|
||||||
currentUrl.hash = '';
|
|
||||||
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
|
|
||||||
const result = await createCheckoutSession(request, mandateId, {
|
|
||||||
userId: currentUser?.id,
|
|
||||||
amount,
|
|
||||||
returnUrl,
|
|
||||||
});
|
|
||||||
if (result?.redirectUrl) {
|
|
||||||
window.location.href = result.redirectUrl;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' });
|
|
||||||
setCheckoutLoading(false);
|
|
||||||
}
|
|
||||||
}, [request]);
|
|
||||||
|
|
||||||
// All user balances (for admin overview cards)
|
|
||||||
const [allUserBalances, setAllUserBalances] = useState<any[]>([]);
|
|
||||||
const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false);
|
|
||||||
|
|
||||||
// Statistics state (shared by Overview and Statistics tabs)
|
// Statistics state (shared by Overview and Statistics tabs)
|
||||||
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
const [viewStats, setViewStats] = useState<ViewStatistics | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Storage volume state (for Statistics tab)
|
||||||
|
const [storageData, setStorageData] = useState<DataVolumeInfo[]>([]);
|
||||||
|
const [storageLoading, setStorageLoading] = useState(false);
|
||||||
|
|
||||||
// Transactions state (for Transactions tab)
|
// Transactions state (for Transactions tab)
|
||||||
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
const [transactions, setTransactions] = useState<UserTransaction[]>([]);
|
||||||
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
const [transactionsLoading, setTransactionsLoading] = useState(false);
|
||||||
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
const [transactionsError, setTransactionsError] = useState<string | null>(null);
|
||||||
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
const [transactionsPagination, setTransactionsPagination] = useState<any>(null);
|
||||||
|
|
||||||
// Load all user balances for admin overview
|
|
||||||
const _loadAllUserBalances = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setAllUserBalancesLoading(true);
|
|
||||||
const response = await api.get('/api/billing/view/users/balances');
|
|
||||||
setAllUserBalances(Array.isArray(response.data) ? response.data : []);
|
|
||||||
} catch {
|
|
||||||
setAllUserBalances([]);
|
|
||||||
} finally {
|
|
||||||
setAllUserBalancesLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load aggregated statistics from the view/statistics route
|
// Load aggregated statistics from the view/statistics route
|
||||||
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -486,15 +422,47 @@ export const BillingDataView: React.FC = () => {
|
||||||
_loadViewStatistics(period, year, month);
|
_loadViewStatistics(period, year, month);
|
||||||
}, [_loadViewStatistics]);
|
}, [_loadViewStatistics]);
|
||||||
|
|
||||||
// Initial data load: load statistics when overview or statistics tab becomes active
|
// Load storage volume for all accessible mandates
|
||||||
|
const _loadStorageData = useCallback(async () => {
|
||||||
|
const mandateIds = new Set<string>();
|
||||||
|
for (const b of balances) {
|
||||||
|
if (selectedScope === 'personal' || selectedScope === 'all' || selectedScope === b.mandateId) {
|
||||||
|
mandateIds.add(b.mandateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mandateIds.size === 0) {
|
||||||
|
setStorageData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStorageLoading(true);
|
||||||
|
try {
|
||||||
|
const mandateNameMap = new Map(balances.map(b => [b.mandateId, b.mandateName]));
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from(mandateIds).map(async (mid) => {
|
||||||
|
try {
|
||||||
|
const resp = await api.get(`/api/subscription/data-volume/${mid}`);
|
||||||
|
return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setStorageData(results.filter((r): r is DataVolumeInfo => r !== null));
|
||||||
|
} catch {
|
||||||
|
setStorageData([]);
|
||||||
|
} finally {
|
||||||
|
setStorageLoading(false);
|
||||||
|
}
|
||||||
|
}, [balances, selectedScope]);
|
||||||
|
|
||||||
|
// Initial data load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'overview' || activeTab === 'statistics') {
|
if (activeTab === 'overview' || activeTab === 'statistics') {
|
||||||
_loadViewStatistics('month', new Date().getFullYear());
|
_loadViewStatistics('month', new Date().getFullYear());
|
||||||
|
_loadStorageData();
|
||||||
}
|
}
|
||||||
if (activeTab === 'overview') {
|
}, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]);
|
||||||
_loadAllUserBalances();
|
|
||||||
}
|
|
||||||
}, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]);
|
|
||||||
|
|
||||||
// Load transactions with pagination support
|
// Load transactions with pagination support
|
||||||
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
||||||
|
|
@ -644,12 +612,6 @@ export const BillingDataView: React.FC = () => {
|
||||||
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
|
const filteredBalances = selectedScope === 'personal' || selectedScope === 'all'
|
||||||
? balances
|
? balances
|
||||||
: balances.filter(b => b.mandateId === selectedScope);
|
: balances.filter(b => b.mandateId === selectedScope);
|
||||||
|
|
||||||
const filteredUserBalances = selectedScope === 'personal'
|
|
||||||
? [] // personal view: only own balance cards, no other users
|
|
||||||
: selectedScope === 'all'
|
|
||||||
? allUserBalances
|
|
||||||
: allUserBalances.filter(ub => ub.mandateId === selectedScope);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -666,35 +628,60 @@ export const BillingDataView: React.FC = () => {
|
||||||
<BalanceCard
|
<BalanceCard
|
||||||
key={balance.mandateId}
|
key={balance.mandateId}
|
||||||
balance={balance}
|
balance={balance}
|
||||||
onCheckout={_handleCheckout}
|
onOpenMandateAdmin={_openMandateBillingAdmin}
|
||||||
checkoutLoading={checkoutLoading}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* All User Balance Cards (mandate/all scope) */}
|
{/* Storage quick info */}
|
||||||
{filteredUserBalances.length > 0 && (
|
{!storageLoading && storageData.length > 0 && (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
|
<h2 className={styles.sectionTitle}>Speicher</h2>
|
||||||
{allUserBalancesLoading ? (
|
<div className={styles.balanceGrid}>
|
||||||
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div>
|
{storageData.map((sv) => {
|
||||||
) : (
|
const pct = sv.percentUsed ?? 0;
|
||||||
<div className={styles.balanceGrid}>
|
const barColor = pct >= 90
|
||||||
{filteredUserBalances.map((ub, idx) => (
|
? 'var(--color-error, #ef4444)'
|
||||||
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
|
: pct >= 70
|
||||||
<div className={styles.balanceHeader}>
|
? 'var(--color-warning, #f59e0b)'
|
||||||
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
|
: 'var(--primary-color, #3b82f6)';
|
||||||
<span className={styles.billingModel}>{ub.mandateName}</span>
|
return (
|
||||||
</div>
|
<div key={sv.mandateId} className={styles.balanceCard}>
|
||||||
<div className={styles.balanceAmount}>
|
<h3 className={styles.mandateName}>{sv.mandateName}</h3>
|
||||||
{_formatCurrency(ub.balance || 0)}
|
<div className={styles.balanceAmount} style={{ fontSize: '1.3rem' }}>
|
||||||
|
{formatBinaryDataSizeFromMebibytes(sv.usedMB)}
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', marginLeft: '6px' }}>
|
||||||
|
/ {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{sv.maxDataVolumeMB != null && (
|
||||||
|
<div style={{
|
||||||
|
height: '6px',
|
||||||
|
background: 'var(--bg-secondary, #2a2a2a)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '10px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.min(pct, 100)}%`,
|
||||||
|
background: barColor,
|
||||||
|
borderRadius: '3px',
|
||||||
|
minWidth: pct > 0 ? '3px' : '0',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sv.warning && (
|
||||||
|
<div className={styles.warningBadge} style={{ marginTop: '8px' }}>
|
||||||
|
Speicher knapp
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -716,18 +703,104 @@ export const BillingDataView: React.FC = () => {
|
||||||
{/* Tab: Statistik (Dashboard) */}
|
{/* Tab: Statistik (Dashboard) */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{activeTab === 'statistics' && (
|
{activeTab === 'statistics' && (
|
||||||
<section className={styles.section}>
|
<>
|
||||||
<FormGeneratorReport
|
{/* Storage volume section */}
|
||||||
title="Nutzungsstatistik"
|
<section className={styles.section}>
|
||||||
subtitle="Detaillierte Analyse der AI-Nutzung"
|
<div className={styles.statisticsChart}>
|
||||||
periodSelector={periodSelectorConfig}
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||||
onFilterChange={_handleStatsFilterChange}
|
Speicherverbrauch
|
||||||
loading={statsLoading}
|
</h3>
|
||||||
sections={statisticsSections}
|
{storageLoading ? (
|
||||||
noDataMessage="Keine Statistiken verfügbar"
|
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
|
||||||
currencyCode="CHF"
|
) : storageData.length === 0 ? (
|
||||||
/>
|
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
|
||||||
</section>
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{storageData.map((sv) => {
|
||||||
|
const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB);
|
||||||
|
const maxLabel = sv.maxDataVolumeMB != null
|
||||||
|
? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB)
|
||||||
|
: 'unbegrenzt';
|
||||||
|
const pct = sv.percentUsed ?? 0;
|
||||||
|
const barColor = pct >= 90
|
||||||
|
? 'var(--color-error, #ef4444)'
|
||||||
|
: pct >= 70
|
||||||
|
? 'var(--color-warning, #f59e0b)'
|
||||||
|
: 'var(--primary-color, #3b82f6)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={sv.mandateId} style={{
|
||||||
|
background: 'var(--bg-secondary, #2a2a2a)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '14px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
||||||
|
{sv.mandateName}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', fontFamily: 'monospace' }}>
|
||||||
|
{usedLabel} / {maxLabel}
|
||||||
|
{sv.percentUsed != null && (
|
||||||
|
<span style={{ marginLeft: '8px', color: barColor, fontWeight: 600 }}>
|
||||||
|
({pct.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sv.maxDataVolumeMB != null && (
|
||||||
|
<div style={{
|
||||||
|
height: '10px',
|
||||||
|
background: 'var(--surface-color, #1e1e1e)',
|
||||||
|
borderRadius: '5px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.min(pct, 100)}%`,
|
||||||
|
background: barColor,
|
||||||
|
borderRadius: '5px',
|
||||||
|
transition: 'width 0.4s ease',
|
||||||
|
minWidth: pct > 0 ? '4px' : '0',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--text-secondary, #888)',
|
||||||
|
}}>
|
||||||
|
<span>Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)}</span>
|
||||||
|
<span>RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* AI usage statistics */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<FormGeneratorReport
|
||||||
|
title="Nutzungsstatistik"
|
||||||
|
subtitle="Detaillierte Analyse der AI-Nutzung"
|
||||||
|
periodSelector={periodSelectorConfig}
|
||||||
|
onFilterChange={_handleStatsFilterChange}
|
||||||
|
loading={statsLoading}
|
||||||
|
sections={statisticsSections}
|
||||||
|
noDataMessage="Keine Statistiken verfügbar"
|
||||||
|
currencyCode="CHF"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
|
|
|
||||||
|
|
@ -38,20 +38,14 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBillingModelLabel = (model: string) => {
|
|
||||||
if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)';
|
|
||||||
return 'Prepaid (Mandant)';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className={styles.transactionsTable}>
|
<table className={styles.transactionsTable}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Mandant</th>
|
<th>Mandant</th>
|
||||||
<th>Billing-Modell</th>
|
|
||||||
<th>Anzahl Benutzer</th>
|
<th>Anzahl Benutzer</th>
|
||||||
<th>Standard-Guthaben</th>
|
<th>Warnschwelle (%)</th>
|
||||||
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
|
<th style={{ textAlign: 'right' }}>Gesamtguthaben</th>
|
||||||
<th>Aktion</th>
|
<th>Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC<MandateBalanceTableProps> = ({
|
||||||
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
|
className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''}
|
||||||
>
|
>
|
||||||
<td>{balance.mandateName || balance.mandateId}</td>
|
<td>{balance.mandateName || balance.mandateId}</td>
|
||||||
<td>{getBillingModelLabel(balance.billingModel)}</td>
|
|
||||||
<td>{balance.userCount}</td>
|
<td>{balance.userCount}</td>
|
||||||
<td>{formatCurrency(balance.defaultUserCredit)}</td>
|
<td>{balance.warningThresholdPercent}%</td>
|
||||||
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
|
<td style={{ textAlign: 'right' }}>{formatCurrency(balance.totalBalance)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSubscription } from '../../hooks/useSubscription';
|
import { useSubscription } from '../../hooks/useSubscription';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
|
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
|
||||||
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
|
||||||
NONE: '—',
|
NONE: '—',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
|
||||||
|
const storageOverageChfPerGbMonth = 0.5;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plan Card
|
// Plan Card
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
<div style={{ fontSize: '0.85rem' }}>
|
<div style={{ fontSize: '0.85rem' }}>
|
||||||
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
|
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary, #888)' }}>
|
||||||
|
AI-Budget (inkl.): <strong>{_formatCurrency(plan.budgetAiCHF ?? 0)}</strong> / Periode
|
||||||
|
{' · '}
|
||||||
|
Speicher (inkl.):{' '}
|
||||||
|
<strong>
|
||||||
|
{plan.maxDataVolumeMB == null
|
||||||
|
? 'unbegrenzt'
|
||||||
|
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: '0.25rem' }}>
|
||||||
|
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
{plan.trialDays} Tage kostenlos
|
{plan.trialDays} Tage kostenlos
|
||||||
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
||||||
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
||||||
|
{(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && (
|
||||||
|
<>
|
||||||
|
{plan.maxDataVolumeMB != null && (
|
||||||
|
<> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
|
||||||
|
)}
|
||||||
|
{(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}</>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -214,6 +239,20 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
||||||
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
||||||
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
||||||
)}
|
)}
|
||||||
|
{plan && (
|
||||||
|
<>
|
||||||
|
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span>
|
||||||
|
<span>
|
||||||
|
Speicher (inkl.):{' '}
|
||||||
|
{plan.maxDataVolumeMB == null
|
||||||
|
? 'unbegrenzt'
|
||||||
|
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||||
|
</span>
|
||||||
|
<span style={{ gridColumn: '1 / -1' }}>
|
||||||
|
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
|
||||||
{
|
{
|
||||||
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
||||||
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
||||||
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
|
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,9 @@ export const AutomationDefinitionsView: React.FC = () => {
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
const hiddenColumns = [
|
const hiddenColumns = [
|
||||||
'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt',
|
'id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt',
|
||||||
'template', 'executionLogs', 'placeholders',
|
'template', 'executionLogs', 'placeholders',
|
||||||
'_createdByUserName', 'mandateName', 'featureInstanceName',
|
'sysCreatedByUserName', 'mandateName', 'featureInstanceName',
|
||||||
];
|
];
|
||||||
const attrColumns = (attributes || [])
|
const attrColumns = (attributes || [])
|
||||||
.filter(attr => !hiddenColumns.includes(attr.name))
|
.filter(attr => !hiddenColumns.includes(attr.name))
|
||||||
|
|
@ -130,7 +130,7 @@ export const AutomationDefinitionsView: React.FC = () => {
|
||||||
const enrichedColumns = [
|
const enrichedColumns = [
|
||||||
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
|
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
|
||||||
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
|
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
|
||||||
{ key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
|
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
|
||||||
];
|
];
|
||||||
return [...attrColumns, ...enrichedColumns];
|
return [...attrColumns, ...enrichedColumns];
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export const AutomationTemplatesView: React.FC = () => {
|
||||||
value ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--info-color, #3182ce)', color: '#fff' }}><FaLock style={{ fontSize: '0.625rem' }} /> System</span>
|
value ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--info-color, #3182ce)', color: '#fff' }}><FaLock style={{ fontSize: '0.625rem' }} /> System</span>
|
||||||
: <span style={{ fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--success-color, #38a169)', color: '#fff' }}>Instanz</span>
|
: <span style={{ fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--success-color, #38a169)', color: '#fff' }}>Instanz</span>
|
||||||
},
|
},
|
||||||
{ key: '_createdByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
|
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
const handleEditClick = async (template: AutomationTemplate) => {
|
const handleEditClick = async (template: AutomationTemplate) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,56 @@
|
||||||
|
/* Outer flex layout: UDB sidebar + main dossier */
|
||||||
|
.dossierLayout {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.2s, min-width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebarCollapsed {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbToggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbToggle:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
.dossier {
|
.dossier {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 140px);
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,54 @@
|
||||||
/**
|
/**
|
||||||
* CommCoach Dossier View (Main View)
|
* CommCoach Dossier View (Main View)
|
||||||
*
|
*
|
||||||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents.
|
* Unified view per context: Coaching session, Tasks, Sessions history, Scores.
|
||||||
* Voice first, always with text fallback.
|
* Voice first, always with text fallback.
|
||||||
|
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||||
import api from '../../../api';
|
|
||||||
import {
|
import {
|
||||||
getDossierExportUrl, getSessionExportUrl,
|
getDossierExportUrl, getSessionExportUrl,
|
||||||
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
|
|
||||||
getScoreHistoryApi, getPersonasApi,
|
getScoreHistoryApi, getPersonasApi,
|
||||||
type CoachingDocument, type CoachingPersona,
|
type CoachingPersona,
|
||||||
} from '../../../api/commcoachApi';
|
} from '../../../api/commcoachApi';
|
||||||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
|
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||||
import styles from './CommcoachDossierView.module.css';
|
import styles from './CommcoachDossierView.module.css';
|
||||||
import { useVoiceController } from './useVoiceController';
|
import { useVoiceController } from './useVoiceController';
|
||||||
|
|
||||||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
|
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||||||
|
|
||||||
export const CommcoachDossierView: React.FC = () => {
|
export const CommcoachDossierView: React.FC = () => {
|
||||||
const coach = useCommcoach();
|
const coach = useCommcoach();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
const mandateId = useMandateId();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||||||
const [showNewContext, setShowNewContext] = useState(false);
|
const [showNewContext, setShowNewContext] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
const [newDescription, setNewDescription] = useState('');
|
const [newDescription, setNewDescription] = useState('');
|
||||||
const [newCategory, setNewCategory] = useState('custom');
|
const [newCategory, setNewCategory] = useState('custom');
|
||||||
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||||
|
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||||
|
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||||
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const _udbContext: UdbContext | null = instanceId
|
||||||
|
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
||||||
|
: null;
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const sendMessageRef = useRef(coach.sendMessage);
|
const sendMessageRef = useRef(coach.sendMessage);
|
||||||
sendMessageRef.current = coach.sendMessage;
|
sendMessageRef.current = coach.sendMessage;
|
||||||
|
|
@ -82,27 +88,14 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||||
|
|
||||||
// Load documents, scores, personas when context changes
|
// Load scores, personas when context changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !coach.selectedContextId) return;
|
if (!instanceId || !coach.selectedContextId) return;
|
||||||
getDocumentsApi(request, instanceId, coach.selectedContextId)
|
|
||||||
.then(d => setDocuments(d))
|
|
||||||
.catch(() => {});
|
|
||||||
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
||||||
.then(h => setScoreHistory(h))
|
.then(h => setScoreHistory(h))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [instanceId, request, coach.selectedContextId]);
|
}, [instanceId, request, coach.selectedContextId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
coach.onDocumentCreatedRef.current = (doc) => {
|
|
||||||
setDocuments(prev => {
|
|
||||||
if (prev.some(d => d.id === doc.id)) return prev;
|
|
||||||
return [doc, ...prev];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return () => { coach.onDocumentCreatedRef.current = null; };
|
|
||||||
}, [coach.onDocumentCreatedRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
getPersonasApi(request, instanceId)
|
getPersonasApi(request, instanceId)
|
||||||
|
|
@ -118,6 +111,15 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [activeTab, coach.session?.id, voice]);
|
}, [activeTab, coach.session?.id, voice]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
coach.onDocumentCreatedRef.current = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
coach.onDocumentCreatedRef.current = null;
|
||||||
|
};
|
||||||
|
}, [coach]);
|
||||||
|
|
||||||
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
|
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
|
||||||
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||||
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||||
|
|
@ -144,46 +146,6 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
coach.selectContext(contextId, { skipSessionResume: true });
|
coach.selectContext(contextId, { skipSessionResume: true });
|
||||||
}, [coach]);
|
}, [coach]);
|
||||||
|
|
||||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file || !instanceId || !coach.selectedContextId) return;
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
|
|
||||||
setDocuments(prev => [doc, ...prev]);
|
|
||||||
} catch { /* upload failed */ } finally {
|
|
||||||
setUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
}, [instanceId, coach.selectedContextId]);
|
|
||||||
|
|
||||||
const handleDeleteDocument = useCallback(async (docId: string) => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
|
||||||
await deleteDocumentApi(request, instanceId, docId);
|
|
||||||
setDocuments(prev => prev.filter(d => d.id !== docId));
|
|
||||||
} catch { /* delete failed */ }
|
|
||||||
}, [instanceId, request]);
|
|
||||||
|
|
||||||
const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => {
|
|
||||||
if (!doc.fileRef) return;
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/files/${doc.fileRef}/download`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
const url = window.URL.createObjectURL(response.data);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = doc.fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Download failed:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddTask = useCallback(async () => {
|
const handleAddTask = useCallback(async () => {
|
||||||
if (!newTaskTitle.trim()) return;
|
if (!newTaskTitle.trim()) return;
|
||||||
await coach.addTask(newTaskTitle);
|
await coach.addTask(newTaskTitle);
|
||||||
|
|
@ -195,7 +157,30 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dossier}>
|
<div className={styles.dossierLayout}>
|
||||||
|
{/* UDB Sidebar */}
|
||||||
|
{_udbContext && (
|
||||||
|
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||||
|
<button
|
||||||
|
className={styles.udbToggle}
|
||||||
|
onClick={() => setUdbCollapsed(v => !v)}
|
||||||
|
title={udbCollapsed ? 'Seitenleiste einblenden' : 'Seitenleiste ausblenden'}
|
||||||
|
>
|
||||||
|
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||||
|
</button>
|
||||||
|
{!udbCollapsed && (
|
||||||
|
<UnifiedDataBar
|
||||||
|
context={_udbContext}
|
||||||
|
activeTab={udbTab}
|
||||||
|
onTabChange={setUdbTab}
|
||||||
|
hideTabs={['chats']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className={styles.dossier}>
|
||||||
{/* Context Selector */}
|
{/* Context Selector */}
|
||||||
<div className={styles.contextSelector}>
|
<div className={styles.contextSelector}>
|
||||||
{coach.contexts.map(ctx => (
|
{coach.contexts.map(ctx => (
|
||||||
|
|
@ -286,13 +271,13 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
|
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
>
|
>
|
||||||
{_tabLabel(tab, coach, documents)}
|
{_tabLabel(tab, coach)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -546,41 +531,9 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{/* DOCUMENTS TAB */}
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{activeTab === 'documents' && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<div className={styles.addTaskRow}>
|
|
||||||
<label className={styles.uploadLabel}>
|
|
||||||
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
|
|
||||||
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{documents.length === 0 ? (
|
|
||||||
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.documentList}>
|
|
||||||
{documents.map(doc => (
|
|
||||||
<div key={doc.id} className={styles.documentItem}>
|
|
||||||
<div className={styles.documentInfo}>
|
|
||||||
<div className={styles.documentName}>{doc.fileName}</div>
|
|
||||||
<div className={styles.documentMeta}>
|
|
||||||
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
|
|
||||||
</div>
|
|
||||||
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
|
|
||||||
</div>
|
|
||||||
<div className={styles.documentActions}>
|
|
||||||
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
|
|
||||||
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
|
|
||||||
{/* #region agent log */}
|
{/* #region agent log */}
|
||||||
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -595,6 +548,7 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
{/* #endregion */}
|
{/* #endregion */}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -607,13 +561,12 @@ function _categoryIcon(category: string): string {
|
||||||
return icons[category] || '*';
|
return icons[category] || '*';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
|
function _tabLabel(tab: TabKey, coach: any): string {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
||||||
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
||||||
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
||||||
case 'scores': return `Bewertungen (${coach.scores.length})`;
|
case 'scores': return `Bewertungen (${coach.scores.length})`;
|
||||||
case 'documents': return `Dokumente (${documents.length})`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -634,12 +587,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
||||||
return Object.values(groups);
|
return Object.values(groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _dimensionLabel(dim: string): string {
|
function _dimensionLabel(dim: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
/**
|
/**
|
||||||
* CommCoach Settings View
|
* CommCoach Settings View
|
||||||
*
|
*
|
||||||
* User profile settings: voice preferences, reminders, email notifications.
|
* Coaching-specific settings: reminders, email notifications, stats.
|
||||||
|
* Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import {
|
import {
|
||||||
getProfileApi, updateProfileApi,
|
getProfileApi, updateProfileApi,
|
||||||
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
|
|
||||||
type CoachingUserProfile,
|
type CoachingUserProfile,
|
||||||
} from '../../../api/commcoachApi';
|
} from '../../../api/commcoachApi';
|
||||||
import styles from './CommcoachSettingsView.module.css';
|
import styles from './CommcoachSettingsView.module.css';
|
||||||
|
|
@ -19,16 +20,11 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
|
|
||||||
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
const [profile, setProfile] = useState<CoachingUserProfile | null>(null);
|
||||||
const [languages, setLanguages] = useState<any[]>([]);
|
|
||||||
const [voices, setVoices] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
const [language, setLanguage] = useState('de-DE');
|
|
||||||
const [voiceId, setVoiceId] = useState('');
|
|
||||||
const [reminderEnabled, setReminderEnabled] = useState(false);
|
const [reminderEnabled, setReminderEnabled] = useState(false);
|
||||||
const [reminderTime, setReminderTime] = useState('09:00');
|
const [reminderTime, setReminderTime] = useState('09:00');
|
||||||
const [emailEnabled, setEmailEnabled] = useState(true);
|
const [emailEnabled, setEmailEnabled] = useState(true);
|
||||||
|
|
@ -38,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [profileData, languagesData] = await Promise.all([
|
const profileData = await getProfileApi(request, instanceId);
|
||||||
getProfileApi(request, instanceId),
|
|
||||||
getVoiceLanguagesApi(request, instanceId),
|
|
||||||
]);
|
|
||||||
setProfile(profileData);
|
setProfile(profileData);
|
||||||
setLanguages(languagesData || []);
|
|
||||||
|
|
||||||
if (profileData) {
|
if (profileData) {
|
||||||
setLanguage(profileData.preferredLanguage || 'de-DE');
|
|
||||||
setVoiceId(profileData.preferredVoice || '');
|
|
||||||
setReminderEnabled(profileData.dailyReminderEnabled || false);
|
setReminderEnabled(profileData.dailyReminderEnabled || false);
|
||||||
setReminderTime(profileData.dailyReminderTime || '09:00');
|
setReminderTime(profileData.dailyReminderTime || '09:00');
|
||||||
setEmailEnabled(profileData.emailSummaryEnabled !== false);
|
setEmailEnabled(profileData.emailSummaryEnabled !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE');
|
|
||||||
setVoices(voicesData || []);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Fehler beim Laden');
|
setError(err.message || 'Fehler beim Laden');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -64,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [request, instanceId]);
|
}, [request, instanceId]);
|
||||||
|
|
||||||
const handleLanguageChange = useCallback(async (newLang: string) => {
|
|
||||||
setLanguage(newLang);
|
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
|
||||||
const voicesData = await getVoiceVoicesApi(request, instanceId, newLang);
|
|
||||||
setVoices(voicesData || []);
|
|
||||||
setVoiceId('');
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [request, instanceId]);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -81,8 +57,6 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
try {
|
||||||
const updated = await updateProfileApi(request, instanceId, {
|
const updated = await updateProfileApi(request, instanceId, {
|
||||||
preferredLanguage: language,
|
|
||||||
preferredVoice: voiceId || null,
|
|
||||||
dailyReminderEnabled: reminderEnabled,
|
dailyReminderEnabled: reminderEnabled,
|
||||||
dailyReminderTime: reminderTime,
|
dailyReminderTime: reminderTime,
|
||||||
emailSummaryEnabled: emailEnabled,
|
emailSummaryEnabled: emailEnabled,
|
||||||
|
|
@ -95,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]);
|
}, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]);
|
||||||
|
|
||||||
const handleTestVoice = useCallback(async () => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
setTesting(true);
|
|
||||||
try {
|
|
||||||
const result = await testVoiceApi(request, instanceId, {
|
|
||||||
language,
|
|
||||||
voiceId: voiceId || undefined,
|
|
||||||
});
|
|
||||||
if (result.success && result.audio) {
|
|
||||||
const audioData = `data:audio/mp3;base64,${result.audio}`;
|
|
||||||
const audio = new Audio(audioData);
|
|
||||||
audio.play();
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError('Sprachtest fehlgeschlagen');
|
|
||||||
} finally {
|
|
||||||
setTesting(false);
|
|
||||||
}
|
|
||||||
}, [request, instanceId, language, voiceId]);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
return <div className={styles.loading}>Einstellungen werden geladen...</div>;
|
||||||
|
|
@ -128,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
{success && <div className={styles.success}>{success}</div>}
|
{success && <div className={styles.success}>{success}</div>}
|
||||||
|
|
||||||
{/* Voice Settings */}
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>Sprache und Stimme</h3>
|
<h3 className={styles.sectionTitle}>Stimme & Sprache</h3>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
||||||
<div className={styles.field}>
|
Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert.
|
||||||
<label className={styles.label}>Sprache</label>
|
</p>
|
||||||
<select className={styles.select} value={language} onChange={e => handleLanguageChange(e.target.value)}>
|
<Link to="/settings" onClick={() => {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}>
|
||||||
{languages.length > 0 ? (
|
Benutzereinstellungen oeffnen (Tab "Stimme & Sprache")
|
||||||
languages.map((lang: any) => (
|
</Link>
|
||||||
<option key={lang.code || lang} value={lang.code || lang}>
|
|
||||||
{lang.name || lang.code || lang}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<option value="de-DE">Deutsch</option>
|
|
||||||
<option value="en-US">English (US)</option>
|
|
||||||
<option value="fr-FR">Francais</option>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.label}>Stimme</label>
|
|
||||||
<div className={styles.voiceRow}>
|
|
||||||
<select className={styles.select} value={voiceId} onChange={e => setVoiceId(e.target.value)}>
|
|
||||||
<option value="">Standard</option>
|
|
||||||
{voices.map((v: any) => (
|
|
||||||
<option key={v.name || v} value={v.name || v}>
|
|
||||||
{v.displayName || v.name || v}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className={styles.testBtn} onClick={handleTestVoice} disabled={testing}>
|
|
||||||
{testing ? 'Teste...' : 'Testen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reminder Settings */}
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>Erinnerungen</h3>
|
<h3 className={styles.sectionTitle}>Erinnerungen</h3>
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label className={styles.checkboxLabel}>
|
<label className={styles.checkboxLabel}>
|
||||||
<input
|
<input type="checkbox" checked={reminderEnabled} onChange={e => setReminderEnabled(e.target.checked)} />
|
||||||
type="checkbox"
|
|
||||||
checked={reminderEnabled}
|
|
||||||
onChange={e => setReminderEnabled(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Taegliche Coaching-Erinnerung per E-Mail
|
Taegliche Coaching-Erinnerung per E-Mail
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{reminderEnabled && (
|
{reminderEnabled && (
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label className={styles.label}>Uhrzeit</label>
|
<label className={styles.label}>Uhrzeit</label>
|
||||||
<input
|
<input type="time" className={styles.input} value={reminderTime} onChange={e => setReminderTime(e.target.value)} />
|
||||||
type="time"
|
|
||||||
className={styles.input}
|
|
||||||
value={reminderTime}
|
|
||||||
onChange={e => setReminderTime(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
<label className={styles.checkboxLabel}>
|
<label className={styles.checkboxLabel}>
|
||||||
<input
|
<input type="checkbox" checked={emailEnabled} onChange={e => setEmailEnabled(e.target.checked)} />
|
||||||
type="checkbox"
|
|
||||||
checked={emailEnabled}
|
|
||||||
onChange={e => setEmailEnabled(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Session-Zusammenfassung per E-Mail senden
|
Session-Zusammenfassung per E-Mail senden
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>Statistik</h3>
|
<h3 className={styles.sectionTitle}>Statistik</h3>
|
||||||
<div className={styles.statsGrid}>
|
<div className={styles.statsGrid}>
|
||||||
<div className={styles.statItem}>
|
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalSessions}</span><span className={styles.statLabel}>Sessions gesamt</span></div>
|
||||||
<span className={styles.statValue}>{profile.totalSessions}</span>
|
<div className={styles.statItem}><span className={styles.statValue}>{profile.totalMinutes}</span><span className={styles.statLabel}>Minuten gesamt</span></div>
|
||||||
<span className={styles.statLabel}>Sessions gesamt</span>
|
<div className={styles.statItem}><span className={styles.statValue}>{profile.streakDays}</span><span className={styles.statLabel}>Aktueller Streak</span></div>
|
||||||
</div>
|
<div className={styles.statItem}><span className={styles.statValue}>{profile.longestStreak}</span><span className={styles.statLabel}>Laengster Streak</span></div>
|
||||||
<div className={styles.statItem}>
|
|
||||||
<span className={styles.statValue}>{profile.totalMinutes}</span>
|
|
||||||
<span className={styles.statLabel}>Minuten gesamt</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<span className={styles.statValue}>{profile.streakDays}</span>
|
|
||||||
<span className={styles.statLabel}>Aktueller Streak</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.statItem}>
|
|
||||||
<span className={styles.statValue}>{profile.longestStreak}</span>
|
|
||||||
<span className={styles.statLabel}>Laengster Streak</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,12 @@
|
||||||
*
|
*
|
||||||
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
* Uses the generic useVoiceStream hook for mic capture + STT streaming.
|
||||||
* Google Streaming STT handles silence detection natively.
|
* Google Streaming STT handles silence detection natively.
|
||||||
|
* STT language is loaded from central voice preferences (/api/voice/preferences).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
|
import api from '../../../api';
|
||||||
|
|
||||||
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted';
|
||||||
|
|
||||||
|
|
@ -30,6 +32,8 @@ export interface VoiceControllerCallbacks {
|
||||||
onInterimText?: (text: string) => void;
|
onInterimText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _DEFAULT_STT_LANGUAGE = 'de-DE';
|
||||||
|
|
||||||
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
||||||
const [state, setState] = useState<VoiceState>('idle');
|
const [state, setState] = useState<VoiceState>('idle');
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
|
|
@ -38,6 +42,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
const cbRef = useRef(callbacks);
|
const cbRef = useRef(callbacks);
|
||||||
cbRef.current = callbacks;
|
cbRef.current = callbacks;
|
||||||
|
|
||||||
|
const sttLanguageRef = useRef<string>(_DEFAULT_STT_LANGUAGE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.get('/api/voice/preferences').then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
|
||||||
|
if (lang) sttLanguageRef.current = lang;
|
||||||
|
}).catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const _dlog = useCallback((tag: string, info?: string) => {
|
const _dlog = useCallback((tag: string, info?: string) => {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
||||||
|
|
@ -68,16 +84,20 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const _startStream = useCallback(() => {
|
||||||
|
return voiceStream.start(sttLanguageRef.current);
|
||||||
|
}, [voiceStream]);
|
||||||
|
|
||||||
const activate = useCallback(async () => {
|
const activate = useCallback(async () => {
|
||||||
if (stateRef.current !== 'idle') return;
|
if (stateRef.current !== 'idle') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
try {
|
try {
|
||||||
await voiceStream.start('de-DE');
|
await _startStream();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_dlog('MIC-ERR', String(err));
|
_dlog('MIC-ERR', String(err));
|
||||||
_setState('idle');
|
_setState('idle');
|
||||||
}
|
}
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const deactivate = useCallback(() => {
|
const deactivate = useCallback(() => {
|
||||||
voiceStream.stop();
|
voiceStream.stop();
|
||||||
|
|
@ -94,15 +114,15 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
const ttsPaused = useCallback(() => {
|
const ttsPaused = useCallback(() => {
|
||||||
if (stateRef.current !== 'botSpeaking') return;
|
if (stateRef.current !== 'botSpeaking') return;
|
||||||
_setState('interrupted');
|
_setState('interrupted');
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const ttsEnded = useCallback(() => {
|
const ttsEnded = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
|
|
@ -110,13 +130,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
if (mutedRef.current) {
|
if (mutedRef.current) {
|
||||||
_setMuted(false);
|
_setMuted(false);
|
||||||
if (cur === 'listening' || cur === 'interrupted') {
|
if (cur === 'listening' || cur === 'interrupted') {
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_setMuted(true);
|
_setMuted(true);
|
||||||
voiceStream.stop();
|
voiceStream.stop();
|
||||||
}
|
}
|
||||||
}, [_setMuted, voiceStream, _dlog]);
|
}, [_setMuted, _startStream, voiceStream, _dlog]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||||
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||||
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
return (attributes || []).filter(attr => !excluded.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes (exclude system fields)
|
// Form attributes (exclude system fields)
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||||
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
if (!attributes || attributes.length === 0) return [];
|
if (!attributes || attributes.length === 0) return [];
|
||||||
|
|
||||||
// Exclude system fields from table columns
|
// Exclude system fields from table columns
|
||||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||||
|
|
||||||
return attributes
|
return attributes
|
||||||
.filter((attr: any) => !excludedFields.includes(attr.name))
|
.filter((attr: any) => !excludedFields.includes(attr.name))
|
||||||
|
|
@ -127,7 +127,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes (exclude system fields)
|
// Form attributes (exclude system fields)
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||||
return (attributes || []).filter((attr: any) => !excludedFields.includes(attr.name));
|
return (attributes || []).filter((attr: any) => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
const positionColumnOrder = [
|
const positionColumnOrder = [
|
||||||
'_documentRefs', // Belege (download icons)
|
'_documentRefs', // Belege (download icons)
|
||||||
'_syncStatus', // Sync-Status
|
'_syncStatus', // Sync-Status
|
||||||
'_createdAt', // Erstellt am
|
'sysCreatedAt', // Erstellt am
|
||||||
'valuta', // Valuta date
|
'valuta', // Valuta date
|
||||||
'tags',
|
'tags',
|
||||||
'company',
|
'company',
|
||||||
|
|
@ -372,7 +372,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes (exclude system fields)
|
// Form attributes (exclude system fields)
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
|
const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy'];
|
||||||
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||||
import type { AgentProgress, FileEditProposal } from './useWorkspace';
|
import type { AgentProgress, FileEditProposal } from './useWorkspace';
|
||||||
|
|
||||||
|
|
@ -147,6 +148,44 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
|
||||||
charCount={(msg as any)._audioCharCount}
|
charCount={(msg as any)._audioCharCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
|
||||||
|
<details className="sentDataDetails" style={{ marginTop: 8, fontSize: '0.8rem', borderTop: '1px solid var(--border-color, #e5e7eb)', paddingTop: 6 }}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #6b7280)', fontWeight: 500, userSelect: 'none' }}>
|
||||||
|
Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'})
|
||||||
|
</summary>
|
||||||
|
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{msg.documents.map((doc, idx) => (
|
||||||
|
<div key={doc.id || idx} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-hover, rgba(0,0,0,0.02))', borderRadius: 4 }}>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{doc.documentName || doc.fileName || `Dokument ${idx + 1}`}
|
||||||
|
</span>
|
||||||
|
{doc.validationMetadata?.neutralized && (
|
||||||
|
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#dcfce7', color: '#166534' }}>
|
||||||
|
neutralisiert
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{doc.validationMetadata?.skipped && (
|
||||||
|
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#fef2f2', color: '#991b1b' }}>
|
||||||
|
übersprungen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(msg as any).neutralizationExcluded?.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6, padding: '6px 8px', background: '#fef2f2', borderRadius: 4, border: '1px solid #fecaca' }}>
|
||||||
|
<div style={{ fontWeight: 600, color: '#991b1b', marginBottom: 4 }}>
|
||||||
|
Nicht gesendet (Neutralisierung fehlgeschlagen):
|
||||||
|
</div>
|
||||||
|
{(msg as any).neutralizationExcluded.map((docName: string, i: number) => (
|
||||||
|
<div key={i} style={{ fontSize: '0.75rem', color: '#991b1b', paddingLeft: 4 }}>
|
||||||
|
{docName}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -301,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) {
|
||||||
|
|
||||||
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
|
const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || '';
|
||||||
const icon = _getFileIcon(ext);
|
const icon = _getFileIcon(ext);
|
||||||
const sizeLabel = doc.fileSize
|
const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : '';
|
||||||
? doc.fileSize > 1024 * 1024
|
|
||||||
? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB`
|
|
||||||
: `${(doc.fileSize / 1024).toFixed(1)} KB`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -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 React, { useState, useEffect } from 'react';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
import type { WorkspaceFile } from './useWorkspace';
|
import type { WorkspaceFile } from './useWorkspace';
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
|
|
@ -76,7 +77,7 @@ export const FilePreview: React.FC<FilePreviewProps> = ({ instanceId, fileId, fi
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
<div style={{ fontSize: 11, color: '#888', marginTop: 2, display: 'flex', gap: 12 }}>
|
||||||
<span>{file.mimeType}</span>
|
<span>{file.mimeType}</span>
|
||||||
<span>{_formatFileSize(file.fileSize)}</span>
|
<span>{formatBinaryDataSizeBytes(file.fileSize)}</span>
|
||||||
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
|
{file.status && <span style={{ color: file.status === 'ready' ? '#4caf50' : '#ff9800' }}>{file.status}</span>}
|
||||||
</div>
|
</div>
|
||||||
{file.description && (
|
{file.description && (
|
||||||
|
|
@ -156,8 +157,3 @@ function _isTextMime(mime: string): boolean {
|
||||||
return textTypes.includes(mime);
|
return textTypes.includes(mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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 { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
|
import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor';
|
||||||
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
|
import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa';
|
||||||
|
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
|
||||||
|
|
||||||
function _getMonacoLanguage(fileName: string): string {
|
function _getMonacoLanguage(fileName: string): string {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string {
|
||||||
return langMap[ext] || 'plaintext';
|
return langMap[ext] || 'plaintext';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WorkspaceEditorPage: React.FC = () => {
|
export const WorkspaceEditorPage: React.FC = () => {
|
||||||
const instanceId = useInstanceId() || '';
|
const instanceId = useInstanceId() || '';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => {
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
|
<div style={{ fontSize: 12, color: '#888', display: 'flex', gap: 16 }}>
|
||||||
<span>{activeEdit.fileName}</span>
|
<span>{activeEdit.fileName}</span>
|
||||||
<span>Original: {_formatBytes(activeEdit.oldContent.length)}</span>
|
<span>Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)}</span>
|
||||||
<span>Geaendert: {_formatBytes(activeEdit.newContent.length)}</span>
|
<span>Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -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 React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import styles from './WorkspaceSettings.module.css';
|
import styles from './WorkspaceGeneralSettings.module.css';
|
||||||
|
|
||||||
interface GeneralSettingsProps {
|
interface GeneralSettingsProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
||||||
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||||
import { getPageIcon } from '../../../config/pageRegistry';
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
||||||
|
|
@ -38,7 +39,7 @@ interface TreeItemDrop {
|
||||||
|
|
||||||
interface WorkspaceInputProps {
|
interface WorkspaceInputProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void;
|
onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
files: WorkspaceFile[];
|
files: WorkspaceFile[];
|
||||||
|
|
@ -48,11 +49,13 @@ interface WorkspaceInputProps {
|
||||||
onRemovePendingFile?: (fileId: string) => void;
|
onRemovePendingFile?: (fileId: string) => void;
|
||||||
onFileUploadClick?: () => void;
|
onFileUploadClick?: () => void;
|
||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
selectedProviders?: string[];
|
providerSelection?: ProviderSelection;
|
||||||
onProvidersChange?: (providers: string[]) => void;
|
onProviderSelectionChange?: (selection: ProviderSelection) => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
|
||||||
onPasteAsFile?: (file: File) => void;
|
onPasteAsFile?: (file: File) => void;
|
||||||
|
draftAppend?: string;
|
||||||
|
onDraftAppendConsumed?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
|
|
@ -67,30 +70,47 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
onRemovePendingFile,
|
onRemovePendingFile,
|
||||||
onFileUploadClick,
|
onFileUploadClick,
|
||||||
uploading = false,
|
uploading = false,
|
||||||
selectedProviders = [],
|
providerSelection,
|
||||||
onProvidersChange,
|
onProviderSelectionChange,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
onTreeItemsDrop,
|
onTreeItemsDrop,
|
||||||
onPasteAsFile,
|
onPasteAsFile,
|
||||||
|
draftAppend,
|
||||||
|
onDraftAppendConsumed,
|
||||||
}) => {
|
}) => {
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||||
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
const [autocompleteFilter, setAutocompleteFilter] = useState('');
|
||||||
const [treeDropOver, setTreeDropOver] = useState(false);
|
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||||
const [voiceActive, setVoiceActive] = useState(false);
|
const [voiceActive, setVoiceActive] = useState(false);
|
||||||
const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE');
|
const [voiceLanguage, setVoiceLanguage] = useState('de-DE');
|
||||||
const [showLangPicker, setShowLangPicker] = useState(false);
|
const [showLangPicker, setShowLangPicker] = useState(false);
|
||||||
|
const _sttPrefsLoaded = useRef(false);
|
||||||
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||||
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||||
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||||
|
const [neutralizeActive, setNeutralizeActive] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (draftAppend) {
|
||||||
|
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
|
||||||
|
onDraftAppendConsumed?.();
|
||||||
|
}
|
||||||
|
}, [draftAppend, onDraftAppendConsumed]);
|
||||||
|
|
||||||
const promptBeforeVoiceRef = useRef('');
|
const promptBeforeVoiceRef = useRef('');
|
||||||
const finalizedTextRef = useRef('');
|
const finalizedTextRef = useRef('');
|
||||||
const currentInterimRef = useRef('');
|
const currentInterimRef = useRef('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('workspace_stt_lang', voiceLanguage);
|
if (_sttPrefsLoaded.current) return;
|
||||||
}, [voiceLanguage]);
|
_sttPrefsLoaded.current = true;
|
||||||
|
fetch('/api/voice/preferences', { credentials: 'include' })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); })
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const _extractFileRefs = useCallback(
|
const _extractFileRefs = useCallback(
|
||||||
(text: string): string[] => {
|
(text: string): string[] => {
|
||||||
|
|
@ -116,12 +136,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
if (!trimmed || isProcessing) return;
|
if (!trimmed || isProcessing) return;
|
||||||
const inlineFileIds = _extractFileRefs(trimmed);
|
const inlineFileIds = _extractFileRefs(trimmed);
|
||||||
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
|
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
|
||||||
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds);
|
const options = neutralizeActive ? { requireNeutralization: true } : undefined;
|
||||||
|
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
setShowAutocomplete(false);
|
setShowAutocomplete(false);
|
||||||
setShowSourcePicker(false);
|
setShowSourcePicker(false);
|
||||||
setAttachedFileIds([]);
|
setAttachedFileIds([]);
|
||||||
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]);
|
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
|
||||||
|
|
||||||
const _handleKeyDown = useCallback(
|
const _handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
|
@ -263,7 +284,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
}, [onPasteAsFile]);
|
}, [onPasteAsFile]);
|
||||||
|
|
||||||
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
|
||||||
if (e.dataTransfer.types.includes('application/tree-items')) {
|
if (
|
||||||
|
e.dataTransfer.types.includes('application/tree-items') ||
|
||||||
|
e.dataTransfer.types.includes('application/chat-id')
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
setTreeDropOver(true);
|
setTreeDropOver(true);
|
||||||
|
|
@ -273,11 +297,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
|
||||||
|
|
||||||
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
|
const _handlePromptDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
setTreeDropOver(false);
|
||||||
|
|
||||||
|
const chatId = e.dataTransfer.getData('application/chat-id');
|
||||||
|
if (chatId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const chatLabel = e.dataTransfer.getData('text/plain');
|
||||||
|
const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
|
||||||
|
setPrompt(prev => (prev ? `${prev} ${ref}` : ref));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
if (treeItemsJson && onTreeItemsDrop) {
|
if (treeItemsJson && onTreeItemsDrop) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setTreeDropOver(false);
|
|
||||||
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
|
||||||
onTreeItemsDrop(items);
|
onTreeItemsDrop(items);
|
||||||
}
|
}
|
||||||
|
|
@ -619,12 +654,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onProvidersChange && (
|
{onProviderSelectionChange && providerSelection && (
|
||||||
<ProviderMultiSelect
|
<ProviderMultiSelect
|
||||||
selectedProviders={selectedProviders}
|
selection={providerSelection}
|
||||||
onChange={onProvidersChange}
|
onChange={onProviderSelectionChange}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
excludeByDefault={['privatellm']}
|
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -665,7 +699,11 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
{_STT_LANGUAGES.map(lang => (
|
{_STT_LANGUAGES.map(lang => (
|
||||||
<div
|
<div
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => { setVoiceLanguage(lang.code); setShowLangPicker(false); }}
|
onClick={() => {
|
||||||
|
setVoiceLanguage(lang.code);
|
||||||
|
setShowLangPicker(false);
|
||||||
|
fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {});
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
padding: '8px 12px', cursor: 'pointer', fontSize: 13,
|
||||||
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
|
background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent',
|
||||||
|
|
@ -681,6 +719,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setNeutralizeActive(v => !v)}
|
||||||
|
title={neutralizeActive ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px', borderRadius: 8, border: '1px solid',
|
||||||
|
borderColor: neutralizeActive ? '#166534' : 'var(--border-color, #d1d5db)',
|
||||||
|
background: neutralizeActive ? '#dcfce7' : 'transparent',
|
||||||
|
cursor: 'pointer', fontSize: '1rem', lineHeight: 1,
|
||||||
|
opacity: neutralizeActive ? 1 : 0.5,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔒
|
||||||
|
</button>
|
||||||
|
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<button
|
<button
|
||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue