Merge branch 'int' into feat/grafical-workflow-editor
This commit is contained in:
commit
a2f9e813aa
125 changed files with 7196 additions and 3680 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
|
||||||
|
|
|
||||||
|
|
@ -281,8 +281,8 @@ export async function fetchWorkflowRuns(
|
||||||
|
|
||||||
export interface CompletedRun extends Automation2Run {
|
export interface CompletedRun extends Automation2Run {
|
||||||
workflowLabel?: string;
|
workflowLabel?: string;
|
||||||
_modifiedAt?: number;
|
sysModifiedAt?: number;
|
||||||
_createdAt?: number;
|
sysCreatedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCompletedRuns(
|
export async function fetchCompletedRuns(
|
||||||
|
|
@ -313,7 +313,7 @@ export interface Automation2Task {
|
||||||
result?: Record<string, unknown>;
|
result?: Record<string, unknown>;
|
||||||
/** Workflow label (enriched by API) */
|
/** Workflow label (enriched by API) */
|
||||||
workflowLabel?: string;
|
workflowLabel?: string;
|
||||||
/** Unix timestamp ms (from _createdAt) */
|
/** Unix timestamp ms (from sysCreatedAt) */
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
/** Optional due date - configurable in future */
|
/** Optional due date - configurable in future */
|
||||||
dueAt?: number;
|
dueAt?: number;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -299,6 +285,13 @@ export async function cancelSessionApi(request: ApiRequestFunction, instanceId:
|
||||||
// Streaming Chat API
|
// Streaming Chat API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
fileIds?: string[];
|
||||||
|
dataSourceIds?: string[];
|
||||||
|
featureDataSourceIds?: string[];
|
||||||
|
allowedProviders?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessageStreamApi(
|
export async function sendMessageStreamApi(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|
@ -307,6 +300,7 @@ export async function sendMessageStreamApi(
|
||||||
onError?: (error: Error) => void,
|
onError?: (error: Error) => void,
|
||||||
onComplete?: () => void,
|
onComplete?: () => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
options?: SendMessageOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
|
@ -318,10 +312,16 @@ export async function sendMessageStreamApi(
|
||||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||||
addCSRFTokenToHeaders(headers);
|
addCSRFTokenToHeaders(headers);
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { content };
|
||||||
|
if (options?.fileIds?.length) body.fileIds = options.fileIds;
|
||||||
|
if (options?.dataSourceIds?.length) body.dataSourceIds = options.dataSourceIds;
|
||||||
|
if (options?.featureDataSourceIds?.length) body.featureDataSourceIds = options.featureDataSourceIds;
|
||||||
|
if (options?.allowedProviders?.length) body.allowedProviders = options.allowedProviders;
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify(body),
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
@ -494,27 +494,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
|
||||||
return data.profile;
|
return data.profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Voice API
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' });
|
|
||||||
return data.languages || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise<any[]> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } });
|
|
||||||
return data.voices || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: {
|
|
||||||
text?: string; language?: string; voiceId?: string;
|
|
||||||
}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Persona API (Iteration 2)
|
// Persona API (Iteration 2)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -535,42 +514,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId:
|
||||||
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Document API (Iteration 2)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingDocument[]> {
|
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' });
|
|
||||||
return data.documents || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise<CoachingDocument> {
|
|
||||||
const baseURL = api.defaults.baseURL || '';
|
|
||||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const authToken = localStorage.getItem('authToken');
|
|
||||||
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
||||||
const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
|
|
||||||
if (pathMatch) {
|
|
||||||
headers['X-Mandate-Id'] = pathMatch[1];
|
|
||||||
headers['X-Instance-Id'] = pathMatch[3];
|
|
||||||
}
|
|
||||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
|
||||||
addCSRFTokenToHeaders(headers);
|
|
||||||
|
|
||||||
const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' });
|
|
||||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
|
||||||
const data = await response.json();
|
|
||||||
return data.document;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise<void> {
|
|
||||||
await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Badge API (Iteration 2)
|
// Badge API (Iteration 2)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export async function createMandate(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a mandate
|
* Soft-delete a mandate (sets enabled=false, 30-day retention)
|
||||||
* Endpoint: DELETE /api/mandates/{mandateId}
|
* Endpoint: DELETE /api/mandates/{mandateId}
|
||||||
*/
|
*/
|
||||||
export async function deleteMandate(
|
export async function deleteMandate(
|
||||||
|
|
@ -134,3 +134,22 @@ export async function deleteMandate(
|
||||||
method: 'delete'
|
method: 'delete'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-delete a mandate with full cascade (irreversible)
|
||||||
|
* Endpoint: DELETE /api/mandates/{mandateId}?force=true
|
||||||
|
*/
|
||||||
|
export async function hardDeleteMandate(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
mandateId: string,
|
||||||
|
confirmName: string
|
||||||
|
): Promise<void> {
|
||||||
|
await request({
|
||||||
|
url: `/api/mandates/${mandateId}`,
|
||||||
|
method: 'delete',
|
||||||
|
params: { force: true },
|
||||||
|
additionalConfig: {
|
||||||
|
headers: { 'X-Confirm-Name': confirmName }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -225,9 +225,20 @@ export function buildSyncFromClickUpList(args: {
|
||||||
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
|
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const statusTriggerRow: TriggerFormFieldRow | null =
|
||||||
|
statusOpts.length > 0
|
||||||
|
? {
|
||||||
|
name: PAYLOAD_STATUS,
|
||||||
|
label: 'Status',
|
||||||
|
type: 'clickup_status',
|
||||||
|
statusOptions: statusOpts,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const standardTrigger: TriggerFormFieldRow[] = [
|
const standardTrigger: TriggerFormFieldRow[] = [
|
||||||
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
|
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
|
||||||
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
|
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
|
||||||
|
...(statusTriggerRow ? [statusTriggerRow] : []),
|
||||||
{ name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' },
|
{ name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' },
|
||||||
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
|
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
|
||||||
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
|
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
|
||||||
|
|
@ -252,8 +263,9 @@ export function buildSyncFromClickUpList(args: {
|
||||||
if (inf) customInput.push(inf);
|
if (inf) customInput.push(inf);
|
||||||
if (tr) customTrigger.push(tr);
|
if (tr) customTrigger.push(tr);
|
||||||
const fid = String((f as ClickUpFieldLike).id ?? '');
|
const fid = String((f as ClickUpFieldLike).id ?? '');
|
||||||
if (fid && inf?.name) {
|
const payloadKey = inf?.name;
|
||||||
customRefs[fid] = createRef(formNodeId, ['payload', inf.name]);
|
if (fid && payloadKey) {
|
||||||
|
customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
|
|
||||||
.treeNode.multiSelected {
|
.treeNode.multiSelected {
|
||||||
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14));
|
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14));
|
||||||
box-shadow: inset 3px 0 0 var(--color-primary, #1976d2);
|
box-shadow: inset 3px 0 0 var(--color-primary, #F25843);
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeNode.multiSelected:hover {
|
.treeNode.multiSelected:hover {
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
|
|
||||||
.treeNode.dropTarget {
|
.treeNode.dropTarget {
|
||||||
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15));
|
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15));
|
||||||
outline: 2px dashed var(--color-primary, #1976d2);
|
outline: 2px dashed var(--color-primary, #F25843);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
|
|
||||||
.renameInput {
|
.renameInput {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid var(--color-primary, #1976d2);
|
border: 1px solid var(--color-primary, #F25843);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
@ -146,7 +146,25 @@
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--color-text-secondary, #999);
|
color: var(--color-text-secondary, #999);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopeIcons {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightZone {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rightZone .actions {
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rootActions {
|
.rootActions {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa';
|
||||||
|
import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
|
||||||
import styles from './FolderTree.module.css';
|
import styles from './FolderTree.module.css';
|
||||||
|
|
||||||
/* ── Public types ──────────────────────────────────────────────────────── */
|
/* ── Public types ──────────────────────────────────────────────────────── */
|
||||||
|
|
@ -30,6 +31,8 @@ export interface FileNode {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
|
scope?: string;
|
||||||
|
neutralize?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeItem {
|
export interface TreeItem {
|
||||||
|
|
@ -62,6 +65,8 @@ export interface FolderTreeProps {
|
||||||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
|
||||||
|
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||||
|
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
||||||
|
|
@ -146,6 +151,22 @@ function _fileIcon(mime?: string): string {
|
||||||
|
|
||||||
/* ── Selection context threaded through the tree ──────────────────────── */
|
/* ── Selection context threaded through the tree ──────────────────────── */
|
||||||
|
|
||||||
|
const _SCOPE_ICONS: Record<string, string> = {
|
||||||
|
personal: '\uD83D\uDC64',
|
||||||
|
featureInstance: '\uD83D\uDC65',
|
||||||
|
mandate: '\uD83C\uDFE2',
|
||||||
|
global: '\uD83C\uDF10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
|
||||||
|
|
||||||
|
const _SCOPE_LABELS: Record<string, string> = {
|
||||||
|
personal: 'Persönlich',
|
||||||
|
featureInstance: 'Instanz',
|
||||||
|
mandate: 'Mandant',
|
||||||
|
global: 'Global',
|
||||||
|
};
|
||||||
|
|
||||||
interface SelectionCtx {
|
interface SelectionCtx {
|
||||||
selectedItemIds: Set<string>;
|
selectedItemIds: Set<string>;
|
||||||
selectedFileIds: string[];
|
selectedFileIds: string[];
|
||||||
|
|
@ -156,6 +177,8 @@ interface SelectionCtx {
|
||||||
onDeleteFile?: (fileId: string) => Promise<void>;
|
onDeleteFile?: (fileId: string) => Promise<void>;
|
||||||
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
|
||||||
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
|
||||||
|
onScopeChange?: (fileId: string, newScope: string) => void;
|
||||||
|
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
/* ── File node (leaf) ─────────────────────────────────────────────────── */
|
||||||
|
|
@ -227,39 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.folderName}>{file.fileName}</span>
|
<span className={styles.folderName}>{file.fileName}</span>
|
||||||
)}
|
)}
|
||||||
{!renaming && file.fileSize != null && (
|
|
||||||
<span className={styles.fileSize}>
|
|
||||||
{(file.fileSize / 1024).toFixed(0)}K
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!renaming && (
|
{!renaming && (
|
||||||
<span className={styles.actions}>
|
<span className={styles.rightZone}>
|
||||||
{sel.onRenameFile && !multiSelected && (
|
<span className={styles.actions}>
|
||||||
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
{sel.onRenameFile && !multiSelected && (
|
||||||
<FaPen />
|
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title="Umbenennen">
|
||||||
</button>
|
<FaPen />
|
||||||
)}
|
|
||||||
{multiSelected && isSelected ? (
|
|
||||||
<>
|
|
||||||
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
|
||||||
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
|
||||||
<FaTrash />
|
|
||||||
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
|
||||||
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
|
||||||
<FaTrash />
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)}
|
||||||
|
{multiSelected && isSelected ? (
|
||||||
|
<>
|
||||||
|
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} Ordner löschen`}>
|
||||||
|
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} Dateien löschen`}>
|
||||||
|
<FaTrash />
|
||||||
|
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
(sel.onDeleteFile || sel.onDeleteFiles) && (
|
||||||
|
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title="Löschen">
|
||||||
|
<FaTrash />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{file.fileSize != null && (
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{(file.fileSize / 1024).toFixed(0)}K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.scope != null && (
|
||||||
|
<span className={styles.scopeIcons}>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!sel.onScopeChange) return;
|
||||||
|
const idx = _SCOPE_CYCLE.indexOf(file.scope!);
|
||||||
|
const next = _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
|
||||||
|
sel.onScopeChange(file.id, next);
|
||||||
|
}}
|
||||||
|
title={`Scope: ${_SCOPE_LABELS[file.scope!] || file.scope} (klicken zum Wechseln)`}
|
||||||
|
style={{ fontSize: 14 }}
|
||||||
|
>
|
||||||
|
{_SCOPE_ICONS[file.scope!] || '\uD83D\uDC64'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.actionBtn}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
sel.onNeutralizeToggle?.(file.id, !file.neutralize);
|
||||||
|
}}
|
||||||
|
title={file.neutralize ? 'Neutralisierung aktiv (klicken zum Deaktivieren)' : 'Neutralisierung aus (klicken zum Aktivieren)'}
|
||||||
|
style={{ fontSize: 14, opacity: file.neutralize ? 1 : 0.4 }}
|
||||||
|
>
|
||||||
|
{'\uD83D\uDD12'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -277,6 +331,7 @@ interface TreeNodeProps {
|
||||||
showFiles: boolean;
|
showFiles: boolean;
|
||||||
filesByFolder: Map<string, FileNode[]>;
|
filesByFolder: Map<string, FileNode[]>;
|
||||||
sel: SelectionCtx;
|
sel: SelectionCtx;
|
||||||
|
promptFolderName: (message: string, options?: PromptOptions) => Promise<string | null>;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
|
||||||
|
|
@ -291,6 +346,7 @@ interface TreeNodeProps {
|
||||||
|
|
||||||
function _TreeNode({
|
function _TreeNode({
|
||||||
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
|
||||||
|
promptFolderName,
|
||||||
onToggle, onSelect,
|
onToggle, onSelect,
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onDownloadFolder,
|
onDownloadFolder,
|
||||||
|
|
@ -321,12 +377,12 @@ function _TreeNode({
|
||||||
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!onCreateFolder) return;
|
if (!onCreateFolder) return;
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) {
|
if (name?.trim()) {
|
||||||
await onCreateFolder(name.trim(), node.id);
|
await onCreateFolder(name.trim(), node.id);
|
||||||
if (!expandedIds.has(node.id)) onToggle(node.id);
|
if (!expandedIds.has(node.id)) onToggle(node.id);
|
||||||
}
|
}
|
||||||
}, [onCreateFolder, node.id, expandedIds, onToggle]);
|
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]);
|
||||||
|
|
||||||
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -488,6 +544,7 @@ function _TreeNode({
|
||||||
showFiles={showFiles}
|
showFiles={showFiles}
|
||||||
filesByFolder={filesByFolder}
|
filesByFolder={filesByFolder}
|
||||||
sel={sel}
|
sel={sel}
|
||||||
|
promptFolderName={promptFolderName}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onCreateFolder={onCreateFolder}
|
onCreateFolder={onCreateFolder}
|
||||||
|
|
@ -517,11 +574,13 @@ export default function FolderTree({
|
||||||
expandedIds: externalExpandedIds, onToggleExpand,
|
expandedIds: externalExpandedIds, onToggleExpand,
|
||||||
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
|
||||||
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
|
||||||
|
onScopeChange, onNeutralizeToggle,
|
||||||
}: FolderTreeProps) {
|
}: FolderTreeProps) {
|
||||||
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
|
||||||
const [rootDropOver, setRootDropOver] = useState(false);
|
const [rootDropOver, setRootDropOver] = useState(false);
|
||||||
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const lastClickedIdRef = useRef<string | null>(null);
|
const lastClickedIdRef = useRef<string | null>(null);
|
||||||
|
const { prompt: promptFolderName, PromptDialog } = usePrompt();
|
||||||
|
|
||||||
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
const expandedIds = externalExpandedIds ?? internalExpandedIds;
|
||||||
|
|
||||||
|
|
@ -634,8 +693,10 @@ export default function FolderTree({
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
onDeleteFiles,
|
onDeleteFiles,
|
||||||
onDeleteFolders,
|
onDeleteFolders,
|
||||||
|
onScopeChange,
|
||||||
|
onNeutralizeToggle,
|
||||||
};
|
};
|
||||||
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]);
|
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]);
|
||||||
|
|
||||||
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -699,7 +760,7 @@ export default function FolderTree({
|
||||||
className={styles.actionBtn}
|
className={styles.actionBtn}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
if (name?.trim()) await onCreateFolder(name.trim(), null);
|
||||||
}}
|
}}
|
||||||
title="Neuer Ordner"
|
title="Neuer Ordner"
|
||||||
|
|
@ -720,6 +781,7 @@ export default function FolderTree({
|
||||||
showFiles={showFiles}
|
showFiles={showFiles}
|
||||||
filesByFolder={filesByFolder}
|
filesByFolder={filesByFolder}
|
||||||
sel={sel}
|
sel={sel}
|
||||||
|
promptFolderName={promptFolderName}
|
||||||
onToggle={_handleToggle}
|
onToggle={_handleToggle}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onCreateFolder={onCreateFolder}
|
onCreateFolder={onCreateFolder}
|
||||||
|
|
@ -736,6 +798,7 @@ export default function FolderTree({
|
||||||
<_FileItem key={file.id} file={file} sel={sel} />
|
<_FileItem key={file.id} file={file} sel={sel} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
|
groupRowData?: (groupKey: string, groupRows: T[]) => Record<string, React.ReactNode>;
|
||||||
groupDefaultExpanded?: boolean;
|
groupDefaultExpanded?: boolean;
|
||||||
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
|
groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode;
|
||||||
|
initialSearchTerm?: string;
|
||||||
rowDraggable?: boolean;
|
rowDraggable?: boolean;
|
||||||
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -327,6 +328,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
groupRowData,
|
groupRowData,
|
||||||
groupDefaultExpanded = true,
|
groupDefaultExpanded = true,
|
||||||
groupActions,
|
groupActions,
|
||||||
|
initialSearchTerm = '',
|
||||||
rowDraggable = false,
|
rowDraggable = false,
|
||||||
onRowDragStart,
|
onRowDragStart,
|
||||||
}: FormGeneratorTableProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
|
|
@ -368,7 +370,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}, [providedColumns, data]);
|
}, [providedColumns, data]);
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
|
const [filterFocused, setFilterFocused] = useState<Record<string, boolean>>({});
|
||||||
// Multi-column sorting: array of sort configs in order of priority
|
// Multi-column sorting: array of sort configs in order of priority
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => {
|
||||||
|
const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel });
|
||||||
|
if (!newLabel || newLabel.trim() === currentLabel) return;
|
||||||
|
try {
|
||||||
|
await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() });
|
||||||
|
refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message));
|
||||||
|
}
|
||||||
|
}, [refresh, prompt, showWarning]);
|
||||||
|
|
||||||
// Build navigation items from blocks
|
|
||||||
// Groups static items into collapsible containers:
|
|
||||||
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
|
|
||||||
// - "Administration": admin items, possibly with subgroups
|
|
||||||
// - Dynamic block (mandates) renders between them
|
|
||||||
const 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);
|
||||||
|
|
@ -42,6 +44,13 @@ export const UserSection: React.FC = () => {
|
||||||
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
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,77 @@
|
||||||
* 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: '💬',
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -128,27 +188,20 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
|
|
@ -156,61 +209,71 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
|
|
||||||
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 allSelected = _isAllSelected(selection);
|
||||||
const isAllSelected = selectedProviders.length === 0 ||
|
const noneSelected = effectiveSelection.length === 0;
|
||||||
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
|
|
||||||
|
|
||||||
const handleToggle = (provider: string) => {
|
const _handleToggle = (provider: string) => {
|
||||||
if (selectedProviders.length === 0) {
|
const isChecked = effectiveSelection.includes(provider);
|
||||||
// Currently "all active" (no restriction) -> make explicit: all except the toggled one
|
|
||||||
onChange(allowedProviders.filter((p) => p !== provider));
|
if (selection.include.includes(PROVIDER_ALL)) {
|
||||||
} else if (selectedProviders.includes(provider)) {
|
// Currently ALL-based: toggle modifies exclude list
|
||||||
// Deactivate: remove from selection
|
if (isChecked) {
|
||||||
const remaining = selectedProviders.filter((p) => p !== provider);
|
onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] });
|
||||||
// If removing leaves all others selected, reset to [] (= all, no restriction)
|
|
||||||
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}
|
||||||
|
|
@ -221,7 +284,6 @@ 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>}
|
||||||
|
|
@ -229,9 +291,9 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
<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>
|
||||||
|
|
@ -249,7 +311,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
<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>
|
||||||
|
|
@ -261,11 +323,7 @@ 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>
|
||||||
|
|
@ -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 #F25843', borderRadius: 8,
|
||||||
|
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 13, fontWeight: 600, color: '#F25843',
|
||||||
|
}}>
|
||||||
|
Dateien hier ablegen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 8px' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#666', textTransform: 'uppercase' }}>Files</span>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
||||||
|
title="Upload files"
|
||||||
|
>
|
||||||
|
{uploading ? '...' : '+'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={_refreshAll}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
|
||||||
|
>
|
||||||
|
{'\u21BB'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={_handleFileInputChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Dateien suchen..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
|
||||||
|
border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
<FolderTree
|
||||||
|
folders={_folderNodes}
|
||||||
|
files={_fileNodes}
|
||||||
|
showFiles={true}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
onSelect={setSelectedFolderId}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
expandedIds={expandedFolderIds}
|
||||||
|
onToggleExpand={toggleFolderExpanded}
|
||||||
|
onRefresh={_refreshAll}
|
||||||
|
onCreateFolder={handleCreateFolder}
|
||||||
|
onRenameFolder={handleRenameFolder}
|
||||||
|
onDeleteFolder={_onDeleteFolder}
|
||||||
|
onMoveFolder={handleMoveFolder}
|
||||||
|
onMoveFolders={handleMoveFolders}
|
||||||
|
onMoveFile={_onMoveFile}
|
||||||
|
onMoveFiles={_onMoveFiles}
|
||||||
|
onRenameFile={_onRenameFile}
|
||||||
|
onDeleteFile={_onDeleteFile}
|
||||||
|
onDeleteFiles={_onDeleteFiles}
|
||||||
|
onDeleteFolders={_onDeleteFolders}
|
||||||
|
onDownloadFolder={handleDownloadFolder}
|
||||||
|
onScopeChange={_onScopeChange}
|
||||||
|
onNeutralizeToggle={_onNeutralizeToggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{_fileNodes.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
|
||||||
|
{searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<span>{'\uD83D\uDC64'} Persönlich</span>
|
||||||
|
<span>{'\uD83D\uDC65'} Instanz</span>
|
||||||
|
<span>{'\uD83C\uDFE2'} Mandant</span>
|
||||||
|
<span>{'\uD83D\uDD12'} Neutralisiert</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilesTab;
|
||||||
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;
|
||||||
|
}
|
||||||
1608
src/components/UnifiedDataBar/SourcesTab.tsx
Normal file
1608
src/components/UnifiedDataBar/SourcesTab.tsx
Normal file
File diff suppressed because it is too large
Load diff
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/components/UnifiedDataBar/UnifiedDataBar.tsx
Normal file
103
src/components/UnifiedDataBar/UnifiedDataBar.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import ChatsTab from './ChatsTab';
|
||||||
|
import FilesTab from './FilesTab';
|
||||||
|
import SourcesTab from './SourcesTab';
|
||||||
|
import styles from './UnifiedDataBar.module.css';
|
||||||
|
|
||||||
|
export type UdbTab = 'chats' | 'files' | 'sources';
|
||||||
|
|
||||||
|
export interface UdbContext {
|
||||||
|
instanceId: string;
|
||||||
|
mandateId?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedDataBarProps {
|
||||||
|
context: UdbContext;
|
||||||
|
activeTab?: UdbTab;
|
||||||
|
onTabChange?: (tab: UdbTab) => void;
|
||||||
|
hideTabs?: UdbTab[];
|
||||||
|
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
|
||||||
|
activeWorkflowId?: string;
|
||||||
|
onCreateNewChat?: () => void;
|
||||||
|
onRenameChat?: (chatId: string, newName: string) => void;
|
||||||
|
onDeleteChat?: (chatId: string) => void;
|
||||||
|
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||||
|
onFileSelect?: (fileId: string) => void;
|
||||||
|
onSourcesChanged?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _TAB_LABELS: Record<UdbTab, Record<string, string>> = {
|
||||||
|
chats: { de: 'Chats', en: 'Chats', fr: 'Chats' },
|
||||||
|
files: { de: 'Dateien', en: 'Files', fr: 'Fichiers' },
|
||||||
|
sources: { de: 'Quellen', en: 'Sources', fr: 'Sources' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
|
context,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
|
hideTabs,
|
||||||
|
onSelectChat,
|
||||||
|
activeWorkflowId,
|
||||||
|
onCreateNewChat,
|
||||||
|
onRenameChat,
|
||||||
|
onDeleteChat,
|
||||||
|
onChatDragStart,
|
||||||
|
onFileSelect,
|
||||||
|
onSourcesChanged,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter(
|
||||||
|
t => !hideTabs?.includes(t),
|
||||||
|
);
|
||||||
|
const [internalTab, setInternalTab] = useState<UdbTab>(controlledTab ?? visibleTabs[0] ?? 'chats');
|
||||||
|
const currentTab = controlledTab ?? internalTab;
|
||||||
|
|
||||||
|
const _handleTabChange = (tab: UdbTab) => {
|
||||||
|
setInternalTab(tab);
|
||||||
|
onTabChange?.(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.udb} ${className || ''}`}>
|
||||||
|
<div className={styles.tabBar}>
|
||||||
|
{visibleTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => _handleTabChange(tab)}
|
||||||
|
>
|
||||||
|
{_TAB_LABELS[tab].de}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
{currentTab === 'chats' && !hideTabs?.includes('chats') && (
|
||||||
|
<ChatsTab
|
||||||
|
context={context}
|
||||||
|
onSelectChat={onSelectChat}
|
||||||
|
onDragStart={onChatDragStart}
|
||||||
|
activeWorkflowId={activeWorkflowId}
|
||||||
|
onCreateNew={onCreateNewChat}
|
||||||
|
onRenameChat={onRenameChat}
|
||||||
|
onDeleteChat={onDeleteChat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentTab === 'files' && !hideTabs?.includes('files') && (
|
||||||
|
<FilesTab
|
||||||
|
context={context}
|
||||||
|
onFileSelect={onFileSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
||||||
|
<SourcesTab context={context} onSourcesChanged={onSourcesChanged} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnifiedDataBar;
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
createTaskApi, updateTaskStatusApi, deleteTaskApi,
|
createTaskApi, updateTaskStatusApi, deleteTaskApi,
|
||||||
type CoachingContext, type CoachingSession, type CoachingMessage,
|
type CoachingContext, type CoachingSession, type CoachingMessage,
|
||||||
type CoachingTask, type CoachingScore, type SSEEvent,
|
type CoachingTask, type CoachingScore, type SSEEvent,
|
||||||
|
type SendMessageOptions,
|
||||||
} from '../api/commcoachApi';
|
} from '../api/commcoachApi';
|
||||||
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
|
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
|
||||||
|
|
||||||
|
|
@ -37,12 +38,14 @@ export interface CommcoachHookReturn {
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
setInputValue: (v: string) => void;
|
setInputValue: (v: string) => void;
|
||||||
|
|
||||||
|
agentToolCalls: Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>;
|
||||||
|
|
||||||
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
|
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
|
||||||
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
||||||
archiveContext: (contextId: string) => Promise<void>;
|
archiveContext: (contextId: string) => Promise<void>;
|
||||||
|
|
||||||
startSession: (personaId?: string) => Promise<void>;
|
startSession: (personaId?: string) => Promise<void>;
|
||||||
sendMessage: (content: string) => Promise<void>;
|
sendMessage: (content: string, options?: SendMessageOptions) => Promise<void>;
|
||||||
sendAudio: (audioBlob: Blob) => Promise<void>;
|
sendAudio: (audioBlob: Blob) => Promise<void>;
|
||||||
completeSession: () => Promise<void>;
|
completeSession: () => Promise<void>;
|
||||||
cancelSession: () => Promise<void>;
|
cancelSession: () => Promise<void>;
|
||||||
|
|
@ -67,9 +70,10 @@ export interface CommcoachHookReturn {
|
||||||
refreshContexts: () => Promise<void>;
|
refreshContexts: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommcoach(): CommcoachHookReturn {
|
export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const routeInstanceId = useInstanceId();
|
||||||
|
const instanceId = instanceIdOverride || routeInstanceId;
|
||||||
|
|
||||||
const [contexts, setContexts] = useState<CoachingContext[]>([]);
|
const [contexts, setContexts] = useState<CoachingContext[]>([]);
|
||||||
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
|
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
|
||||||
|
|
@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [agentToolCalls, setAgentToolCalls] = useState<Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>>([]);
|
||||||
|
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -239,6 +244,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingStatus(null);
|
setStreamingStatus(null);
|
||||||
|
setStreamingMessage(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -259,7 +265,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setMessages(eventData.messages);
|
setMessages(eventData.messages);
|
||||||
}
|
}
|
||||||
} else if (eventType === 'messageChunk' && eventData) {
|
} else if (eventType === 'messageChunk' && eventData) {
|
||||||
setStreamingMessage(eventData.accumulated || '');
|
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
|
||||||
} else if (eventType === 'message' && eventData) {
|
} else if (eventType === 'message' && eventData) {
|
||||||
setStreamingMessage(null);
|
setStreamingMessage(null);
|
||||||
const msg: CoachingMessage = {
|
const msg: CoachingMessage = {
|
||||||
|
|
@ -313,7 +319,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
}
|
}
|
||||||
}, [instanceId, selectedContextId, ttsPlayback.play]);
|
}, [instanceId, selectedContextId, ttsPlayback.play]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (content: string) => {
|
const sendMessage = useCallback(async (content: string, options?: SendMessageOptions) => {
|
||||||
const normalizedContent = content.trim();
|
const normalizedContent = content.trim();
|
||||||
if (!normalizedContent || !instanceId || !session) return;
|
if (!normalizedContent || !instanceId || !session) return;
|
||||||
|
|
||||||
|
|
@ -326,6 +332,8 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingStatus(null);
|
setStreamingStatus(null);
|
||||||
|
setStreamingMessage(null);
|
||||||
|
setAgentToolCalls([]);
|
||||||
|
|
||||||
const tempMsg: CoachingMessage = {
|
const tempMsg: CoachingMessage = {
|
||||||
id: `temp-${Date.now()}`,
|
id: `temp-${Date.now()}`,
|
||||||
|
|
@ -350,7 +358,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const eventData = event.data;
|
const eventData = event.data;
|
||||||
|
|
||||||
if (eventType === 'messageChunk' && eventData) {
|
if (eventType === 'messageChunk' && eventData) {
|
||||||
setStreamingMessage(eventData.accumulated || '');
|
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
|
||||||
} else if (eventType === 'message' && eventData) {
|
} else if (eventType === 'message' && eventData) {
|
||||||
setStreamingMessage(null);
|
setStreamingMessage(null);
|
||||||
const msg: CoachingMessage = {
|
const msg: CoachingMessage = {
|
||||||
|
|
@ -374,6 +382,17 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
ttsPlayback.play(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'status' && eventData) {
|
} else if (eventType === 'status' && eventData) {
|
||||||
setStreamingStatus(eventData.label || null);
|
setStreamingStatus(eventData.label || null);
|
||||||
|
} else if (eventType === 'toolCall' && eventData) {
|
||||||
|
setAgentToolCalls(prev => [...prev, { toolName: eventData.toolName, args: eventData.args }]);
|
||||||
|
setStreamingStatus(`Tool: ${eventData.toolName}...`);
|
||||||
|
} else if (eventType === 'toolResult' && eventData) {
|
||||||
|
setAgentToolCalls(prev => prev.map((tc, idx) =>
|
||||||
|
idx === prev.length - 1
|
||||||
|
? { ...tc, result: eventData.data?.slice(0, 200), success: eventData.success }
|
||||||
|
: tc
|
||||||
|
));
|
||||||
|
} else if (eventType === 'agentProgress' && eventData) {
|
||||||
|
setStreamingStatus(`Runde ${eventData.round}/${eventData.maxRounds}...`);
|
||||||
} else if (eventType === 'taskCreated' && eventData) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
setTasks(prev => [eventData, ...prev]);
|
setTasks(prev => [eventData, ...prev]);
|
||||||
} else if (eventType === 'documentCreated' && eventData) {
|
} else if (eventType === 'documentCreated' && eventData) {
|
||||||
|
|
@ -400,6 +419,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ac.signal,
|
ac.signal,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === 'AbortError') return;
|
if (err.name === 'AbortError') return;
|
||||||
|
|
@ -417,6 +437,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingStatus(null);
|
setStreamingStatus(null);
|
||||||
|
setStreamingMessage(null);
|
||||||
try {
|
try {
|
||||||
await sendAudioStreamApi(
|
await sendAudioStreamApi(
|
||||||
instanceId,
|
instanceId,
|
||||||
|
|
@ -427,7 +448,9 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const eventType = event.type;
|
const eventType = event.type;
|
||||||
const eventData = event.data;
|
const eventData = event.data;
|
||||||
|
|
||||||
if (eventType === 'status' && eventData) {
|
if (eventType === 'messageChunk' && eventData) {
|
||||||
|
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
|
||||||
|
} else if (eventType === 'status' && eventData) {
|
||||||
setStreamingStatus(eventData.label || null);
|
setStreamingStatus(eventData.label || null);
|
||||||
} else if (eventType === 'message' && eventData) {
|
} else if (eventType === 'message' && eventData) {
|
||||||
if (eventData.role === 'assistant') setError(null);
|
if (eventData.role === 'assistant') setError(null);
|
||||||
|
|
@ -555,6 +578,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
session, messages, isStreaming, streamingStatus, streamingMessage,
|
session, messages, isStreaming, streamingStatus, streamingMessage,
|
||||||
tasks, scores, sessions,
|
tasks, scores, sessions,
|
||||||
error, inputValue, setInputValue,
|
error, inputValue, setInputValue,
|
||||||
|
agentToolCalls,
|
||||||
selectContext, createContext, archiveContext,
|
selectContext, createContext, archiveContext,
|
||||||
startSession: startSessionCb,
|
startSession: startSessionCb,
|
||||||
sendMessage, sendAudio,
|
sendMessage, sendAudio,
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ export function useConfirm() {
|
||||||
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||||
border: '1px solid var(--color-border, #444)',
|
border: '1px solid var(--color-border, #444)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'var(--text-secondary, #aaa)',
|
color: 'var(--text-primary, #e8e8e8)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -116,7 +116,7 @@ export function useConfirm() {
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)',
|
background: isDanger ? '#ef4444' : 'var(--primary-color, #F25843)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export interface Invitation {
|
||||||
export interface InvitationCreate {
|
export interface InvitationCreate {
|
||||||
/** Username of the user to invite (optional when email is provided) */
|
/** Username of the user to invite (optional when email is provided) */
|
||||||
targetUsername?: string;
|
targetUsername?: string;
|
||||||
/** Email address to send invitation link (required for new users) */
|
/** Email to send invitation link; optional if targetUsername is set */
|
||||||
email?: string;
|
email?: string;
|
||||||
roleIds: string[];
|
roleIds: string[];
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
createMandate as createMandateApi,
|
createMandate as createMandateApi,
|
||||||
updateMandate as updateMandateApi,
|
updateMandate as updateMandateApi,
|
||||||
deleteMandate as deleteMandateApi,
|
deleteMandate as deleteMandateApi,
|
||||||
|
hardDeleteMandate as hardDeleteMandateApi,
|
||||||
type Mandate,
|
type Mandate,
|
||||||
type MandateUpdateData,
|
type MandateUpdateData,
|
||||||
type PaginationParams
|
type PaginationParams
|
||||||
|
|
@ -203,6 +204,19 @@ export function useAdminMandates() {
|
||||||
}
|
}
|
||||||
}, [request, fetchMandates]);
|
}, [request, fetchMandates]);
|
||||||
|
|
||||||
|
// Hard-delete mandate (irreversible)
|
||||||
|
const handleHardDelete = useCallback(async (mandateId: string, confirmName: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
removeOptimistically(mandateId);
|
||||||
|
await hardDeleteMandateApi(request, mandateId, confirmName);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error hard-deleting mandate:', error);
|
||||||
|
await fetchMandates();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [request, fetchMandates]);
|
||||||
|
|
||||||
// Inline update
|
// Inline update
|
||||||
const handleInlineUpdate = useCallback(async (
|
const handleInlineUpdate = useCallback(async (
|
||||||
mandateId: string,
|
mandateId: string,
|
||||||
|
|
@ -231,6 +245,7 @@ export function useAdminMandates() {
|
||||||
handleCreate,
|
handleCreate,
|
||||||
handleUpdate,
|
handleUpdate,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
|
handleHardDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 } from 'react';
|
||||||
|
|
||||||
|
export interface PromptOptions {
|
||||||
|
title?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
variant?: 'primary' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PromptState {
|
||||||
|
message: string;
|
||||||
|
options: Required<PromptOptions>;
|
||||||
|
resolve: (value: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaults: Required<PromptOptions> = {
|
||||||
|
title: 'Eingabe',
|
||||||
|
confirmLabel: 'OK',
|
||||||
|
cancelLabel: 'Abbrechen',
|
||||||
|
placeholder: '',
|
||||||
|
defaultValue: '',
|
||||||
|
variant: 'primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePrompt() {
|
||||||
|
const [state, setState] = useState<PromptState | null>(null);
|
||||||
|
const resolveRef = useRef<((v: string | null) => void) | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const prompt = useCallback((message: string, options?: PromptOptions): Promise<string | null> => {
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
resolveRef.current = resolve;
|
||||||
|
setState({
|
||||||
|
message,
|
||||||
|
options: { ..._defaults, ...options },
|
||||||
|
resolve,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleConfirm = useCallback(() => {
|
||||||
|
const val = inputRef.current?.value ?? '';
|
||||||
|
resolveRef.current?.(val);
|
||||||
|
resolveRef.current = null;
|
||||||
|
setState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleCancel = useCallback(() => {
|
||||||
|
resolveRef.current?.(null);
|
||||||
|
resolveRef.current = null;
|
||||||
|
setState(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const PromptDialog: React.FC = useCallback(() => {
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
const { message, options } = state;
|
||||||
|
const isDanger = options.variant === 'danger';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={_handleCancel}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 10000,
|
||||||
|
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-color, #1a1a2e)',
|
||||||
|
border: '1px solid var(--border-color, var(--color-border, #333))',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
minWidth: 360, maxWidth: 500,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0, fontSize: '1.05rem', fontWeight: 600,
|
||||||
|
color: 'var(--text-primary, #e0e0e0)',
|
||||||
|
}}>
|
||||||
|
{options.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
margin: 0, fontSize: '0.9rem', lineHeight: 1.5,
|
||||||
|
color: 'var(--text-secondary, #999)',
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
defaultValue={options.defaultValue}
|
||||||
|
placeholder={options.placeholder}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') _handleConfirm();
|
||||||
|
if (e.key === 'Escape') _handleCancel();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border-color, var(--color-border, #ccc))',
|
||||||
|
background: 'var(--input-bg, var(--bg-primary, #ffffff))',
|
||||||
|
color: 'var(--text-primary, #1a1a1a)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
outline: 'none',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={_handleCancel}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||||
|
border: '1px solid var(--color-border, #444)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--text-secondary, #aaa)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={_handleConfirm}
|
||||||
|
style={{
|
||||||
|
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
|
||||||
|
border: 'none',
|
||||||
|
background: isDanger ? '#ef4444' : 'var(--primary-color, #F25843)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [state, _handleConfirm, _handleCancel]);
|
||||||
|
|
||||||
|
return { prompt, PromptDialog };
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,11 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
|
||||||
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
|
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
|
||||||
const [scheduled, setScheduled] = useState<MandateSubscription | null>(null);
|
const [scheduled, setScheduled] = useState<MandateSubscription | null>(null);
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
const { request, isLoading: loading, error: apiError } = useApiRequest();
|
const { request, isLoading: loading, error: apiError, clearCache } = useApiRequest();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadPlans = useCallback(async () => {
|
const loadPlans = useCallback(async () => {
|
||||||
|
clearCache('/api/subscription/plans', 'get');
|
||||||
try {
|
try {
|
||||||
const data = await fetchSelectablePlans(request, mandateId);
|
const data = await fetchSelectablePlans(request, mandateId);
|
||||||
setPlans(Array.isArray(data) ? data : []);
|
setPlans(Array.isArray(data) ? data : []);
|
||||||
|
|
@ -53,9 +54,10 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
|
||||||
console.error('Error loading plans:', err);
|
console.error('Error loading plans:', err);
|
||||||
setPlans([]);
|
setPlans([]);
|
||||||
}
|
}
|
||||||
}, [request, mandateId]);
|
}, [request, mandateId, clearCache]);
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async () => {
|
||||||
|
clearCache('/api/subscription/status', 'get');
|
||||||
try {
|
try {
|
||||||
const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId);
|
const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId);
|
||||||
setActive(data.active);
|
setActive(data.active);
|
||||||
|
|
@ -69,7 +71,7 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn {
|
||||||
setPlan(null);
|
setPlan(null);
|
||||||
setScheduled(null);
|
setScheduled(null);
|
||||||
}
|
}
|
||||||
}, [request, mandateId]);
|
}, [request, mandateId, clearCache]);
|
||||||
|
|
||||||
const activatePlan = useCallback(async (planKey: string) => {
|
const activatePlan = useCallback(async (planKey: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@
|
||||||
/* Let child components handle their own scrolling for sticky headers */
|
/* Let child components handle their own scrolling for sticky headers */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fills .content flex column so admin pages get a bounded height for inner scroll */
|
/* Fills .content flex column so admin pages get a bounded height for inner scroll */
|
||||||
|
|
@ -168,6 +169,7 @@
|
||||||
|
|
||||||
:global(.dark-theme) .content {
|
:global(.dark-theme) .content {
|
||||||
background: var(--bg-dark, #0a0a0a);
|
background: var(--bg-dark, #0a0a0a);
|
||||||
|
color: var(--text-primary, #e5e7eb);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark-theme) .mobileMenuButton {
|
:global(.dark-theme) .mobileMenuButton {
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||||
import { UserSection } from '../components/Navigation/UserSection';
|
import { UserSection } from '../components/Navigation/UserSection';
|
||||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
||||||
|
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||||
|
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// INNER LAYOUT (mit Zugriff auf Store)
|
// INNER LAYOUT (mit Zugriff auf Store)
|
||||||
|
|
@ -23,6 +25,9 @@ const MainLayoutInner: React.FC = () => {
|
||||||
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
|
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
|
||||||
|
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
|
||||||
|
const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible;
|
||||||
|
|
||||||
// Features laden beim Mount
|
// Features laden beim Mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -105,11 +110,12 @@ const MainLayoutInner: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
|
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
||||||
|
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.outletShell}
|
className={styles.outletShell}
|
||||||
style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : undefined }}
|
style={{ display: hideOutletShell ? 'none' : undefined }}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommCoach coaching/dossier is rendered persistently by CommcoachKeepAlive at MainLayout level.
|
||||||
|
if (featureCode === 'commcoach' && (view === 'coaching' || view === 'dossier')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// View-Komponente finden
|
// View-Komponente finden
|
||||||
const featureViews = VIEW_COMPONENTS[featureCode];
|
const featureViews = VIEW_COMPONENTS[featureCode];
|
||||||
if (!featureViews) {
|
if (!featureViews) {
|
||||||
|
|
|
||||||
|
|
@ -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,13 +22,14 @@ function Login() {
|
||||||
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
|
||||||
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
||||||
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
||||||
|
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
|
||||||
|
|
||||||
// Check for pending invitation
|
// Check for pending invitation
|
||||||
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
|
||||||
const hasPendingInvitation = !!pendingInvitationToken;
|
const hasPendingInvitation = !!pendingInvitationToken;
|
||||||
|
|
||||||
// Get the page the user was trying to visit
|
const fromLocation = location.state?.from;
|
||||||
const from = location.state?.from?.pathname || "/";
|
const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
|
||||||
|
|
||||||
// Set page title and generate CSRF token
|
// Set page title and generate CSRF token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -84,6 +86,10 @@ function Login() {
|
||||||
console.log("Attempting Google login...");
|
console.log("Attempting Google login...");
|
||||||
const response = await loginWithGoogle();
|
const response = await loginWithGoogle();
|
||||||
console.log("Google login successful:", response);
|
console.log("Google login successful:", response);
|
||||||
|
if (response?.isNewUser) {
|
||||||
|
setShowOnboardingWizard(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleSuccessfulLogin();
|
handleSuccessfulLogin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Google login failed:", error);
|
console.error("Google login failed:", error);
|
||||||
|
|
@ -104,6 +110,21 @@ function Login() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showOnboardingWizard) {
|
||||||
|
return (
|
||||||
|
<OnboardingWizard
|
||||||
|
onComplete={() => {
|
||||||
|
setShowOnboardingWizard(false);
|
||||||
|
handleSuccessfulLogin();
|
||||||
|
}}
|
||||||
|
onDismiss={() => {
|
||||||
|
setShowOnboardingWizard(false);
|
||||||
|
handleSuccessfulLogin();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
|
|
@ -213,12 +234,15 @@ function Login() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={styles.registerLink}>
|
<div className={styles.registerLink}>
|
||||||
<span>Du hast noch keinen Konto?</span>
|
<span>Du hast noch kein Konto?</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.ctaSection}>
|
||||||
<button
|
<button
|
||||||
className={styles.textButton}
|
type="button"
|
||||||
onClick={() => navigate("/register", { state: location.state })}
|
className={styles.ctaPrimary}
|
||||||
|
onClick={() => navigate('/register', { state: location.state })}
|
||||||
>
|
>
|
||||||
Registrieren
|
Kostenlos registrieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -28,36 +42,10 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
||||||
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) => {
|
||||||
|
|
@ -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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -108,265 +433,141 @@ export const SettingsPage: React.FC = () => {
|
||||||
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.settingInfo}>
|
||||||
|
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||||
|
<p className={styles.settingDescription}>Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.settingControl}>
|
||||||
|
<button className={styles.button} onClick={async () => { await refetchUser(); setIsProfileModalOpen(true); }}>Profil oeffnen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentUser && (
|
||||||
|
<div className={styles.userInfoCard}>
|
||||||
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Benutzername</span><span className={styles.userInfoValue}>{currentUser.username}</span></div>
|
||||||
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>Name</span><span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span></div>
|
||||||
|
<div className={styles.userInfoRow}><span className={styles.userInfoLabel}>E-Mail</span><span className={styles.userInfoValue}>{currentUser.email || '-'}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Ueber</h2>
|
||||||
|
<div className={styles.infoCard}>
|
||||||
|
<div className={styles.infoRow}><span className={styles.infoLabel}>Version</span><span className={styles.infoValue}>2.0.0</span></div>
|
||||||
|
<div className={styles.infoRow}><span className={styles.infoLabel}>Build</span><span className={styles.infoValue}>2026.03.23</span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.settingRow}>
|
{activeTab === 'appearance' && (
|
||||||
<div className={styles.settingInfo}>
|
<section className={styles.section}>
|
||||||
<label className={styles.settingLabel}>Theme</label>
|
<h2 className={styles.sectionTitle}>Darstellung</h2>
|
||||||
<p className={styles.settingDescription}>
|
<div className={styles.settingRow}>
|
||||||
Wähle zwischen hellem und dunklem Design.
|
<div className={styles.settingInfo}><label className={styles.settingLabel}>Theme</label><p className={styles.settingDescription}>Waehlen Sie zwischen hellem und dunklem Design.</p></div>
|
||||||
</p>
|
<div className={styles.settingControl}>
|
||||||
</div>
|
<div className={styles.themeToggle}>
|
||||||
<div className={styles.settingControl}>
|
<button className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} onClick={() => handleThemeChange('light')}>Hell</button>
|
||||||
<div className={styles.themeToggle}>
|
<button className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} onClick={() => handleThemeChange('dark')}>Dunkel</button>
|
||||||
<button
|
</div>
|
||||||
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 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>
|
|
||||||
|
|
||||||
<div className={styles.settingRow}>
|
{activeTab === 'neutralization' && <NeutralizationMappingsTab />}
|
||||||
<div className={styles.settingInfo}>
|
|
||||||
<label className={styles.settingLabel}>GDPR / Privacy</label>
|
|
||||||
<p className={styles.settingDescription}>
|
|
||||||
Data export, portability and account deletion.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={styles.settingControl}>
|
|
||||||
<Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">
|
|
||||||
Open GDPR page
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Info */}
|
{activeTab === 'privacy' && (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Über</h2>
|
<h2 className={styles.sectionTitle}>Datenschutz</h2>
|
||||||
|
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||||
<div className={styles.infoCard}>
|
Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich
|
||||||
<div className={styles.infoRow}>
|
nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export,
|
||||||
<span className={styles.infoLabel}>Version</span>
|
Löschung) finden Sie unter GDPR.
|
||||||
<span className={styles.infoValue}>2.0.0</span>
|
</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.infoRow}>
|
</section>
|
||||||
<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}
|
||||||
|
|
|
||||||
|
|
@ -550,8 +550,8 @@
|
||||||
|
|
||||||
.statusBadge.starting,
|
.statusBadge.starting,
|
||||||
.statusBadge.running {
|
.statusBadge.running {
|
||||||
background: #e3f2fd;
|
background: var(--primary-dark-bg, rgba(242, 88, 67, 0.12));
|
||||||
color: #1976d2;
|
color: var(--primary-color, #F25843);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBadge.completed {
|
.statusBadge.completed {
|
||||||
|
|
@ -617,7 +617,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.logStatus {
|
.logStatus {
|
||||||
color: #1976d2;
|
color: var(--primary-color, #F25843);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logEntryError .logStatus,
|
.logEntryError .logStatus,
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,17 @@ import {
|
||||||
splitMandateAndBillingFromForm,
|
splitMandateAndBillingFromForm,
|
||||||
} from '../../utils/mandateBillingFormMerge';
|
} from '../../utils/mandateBillingFormMerge';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
export const AdminMandatesPage: React.FC = () => {
|
export const AdminMandatesPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showWarning, showSuccess } = useToast();
|
const { showWarning, showSuccess } = useToast();
|
||||||
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const {
|
const {
|
||||||
mandates,
|
mandates,
|
||||||
columns,
|
columns,
|
||||||
|
|
@ -35,6 +37,7 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
handleCreate,
|
handleCreate,
|
||||||
handleUpdate,
|
handleUpdate,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
|
handleHardDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
} = useAdminMandates();
|
} = useAdminMandates();
|
||||||
|
|
@ -111,15 +114,42 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
setEditingBillingWarning(null);
|
setEditingBillingWarning(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete (confirmation handled by DeleteActionButton)
|
|
||||||
// System mandates (isSystem=true) are protected from deletion
|
|
||||||
const handleDeleteMandate = async (mandate: Mandate) => {
|
const handleDeleteMandate = async (mandate: Mandate) => {
|
||||||
if (mandate.isSystem) {
|
if (mandate.isSystem) {
|
||||||
return; // Safety guard - should not be reachable due to disabled button
|
return;
|
||||||
|
}
|
||||||
|
const entered = await prompt(
|
||||||
|
`Um den Mandanten "${mandate.name}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:`,
|
||||||
|
{ title: 'Mandant deaktivieren', confirmLabel: 'Deaktivieren', variant: 'danger', placeholder: mandate.name },
|
||||||
|
);
|
||||||
|
if (entered === null) return;
|
||||||
|
if (entered !== mandate.name) {
|
||||||
|
showWarning('Abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await handleDelete(mandate.id);
|
await handleDelete(mandate.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleHardDeleteMandate = async (mandate: Mandate) => {
|
||||||
|
if (mandate.isSystem) {
|
||||||
|
showWarning('Nicht erlaubt', 'System-Mandanten können nicht gelöscht werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entered = await prompt(
|
||||||
|
`ACHTUNG: Dies löscht den Mandanten "${mandate.name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:`,
|
||||||
|
{ title: 'Hard Delete (irreversibel)', confirmLabel: 'Endgültig löschen', variant: 'danger', placeholder: mandate.name },
|
||||||
|
);
|
||||||
|
if (entered === null) return;
|
||||||
|
if (entered !== mandate.name) {
|
||||||
|
showWarning('Abgebrochen', 'Der eingegebene Name stimmt nicht überein.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await handleHardDelete(mandate.id, entered);
|
||||||
|
if (ok) {
|
||||||
|
showSuccess('Gelöscht', `Mandant "${mandate.name}" wurde endgültig gelöscht.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
|
@ -209,12 +239,21 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
}] : []),
|
}] : []),
|
||||||
...(canDelete ? [{
|
...(canDelete ? [{
|
||||||
type: 'delete' as const,
|
type: 'delete' as const,
|
||||||
title: 'Löschen',
|
title: 'Deaktivieren (Soft-Delete)',
|
||||||
disabled: (row: Mandate) => row.isSystem
|
disabled: (row: Mandate) => row.isSystem
|
||||||
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
|
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
|
||||||
: false
|
: false
|
||||||
}] : []),
|
}] : []),
|
||||||
]}
|
]}
|
||||||
|
customActions={canDelete ? [{
|
||||||
|
id: 'hard-delete',
|
||||||
|
icon: <FaSkullCrossbones />,
|
||||||
|
onClick: handleHardDeleteMandate,
|
||||||
|
title: 'Hard Delete (irreversibel)',
|
||||||
|
disabled: (row: Mandate) => row.isSystem
|
||||||
|
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
|
||||||
|
: false,
|
||||||
|
}] : []}
|
||||||
onDelete={handleDeleteMandate}
|
onDelete={handleDeleteMandate}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch,
|
||||||
|
|
@ -267,6 +306,8 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PromptDialog />
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
{editingFormData && (
|
{editingFormData && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* 4-step invitation wizard:
|
* 4-step invitation wizard:
|
||||||
* 1. Choose invite type: "Invite to mandate" OR "Invite to feature instance"
|
* 1. Choose invite type: "Invite to mandate" OR "Invite to feature instance"
|
||||||
* 2. Select mandate (and feature instance if applicable)
|
* 2. Select mandate (and feature instance if applicable)
|
||||||
* 3. Add invitees (email required, username optional; existing users; role per invitee)
|
* 3. Add invitees (mindestens E-Mail oder Benutzername für neue Benutzer; bestehende Benutzer; Rolle pro Einladung)
|
||||||
* 4. Summary and send
|
* 4. Summary and send
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -167,13 +167,24 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
|
|
||||||
const addInviteeByEmail = () => {
|
const addInviteeByEmail = () => {
|
||||||
const email = inviteeForm.email.trim();
|
const email = inviteeForm.email.trim();
|
||||||
if (!email) {
|
const username = inviteeForm.username.trim();
|
||||||
setError('E-Mail ist erforderlich');
|
if (!email && !username) {
|
||||||
|
setError('Bitte mindestens eine E-Mail-Adresse oder einen Benutzernamen angeben.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emailLower = email.toLowerCase();
|
||||||
|
const userLower = username.toLowerCase();
|
||||||
|
if (email && invitees.some(i => !i.isExisting && (i.email || '').toLowerCase() === emailLower)) {
|
||||||
|
setError('Diese E-Mail ist bereits in der Liste');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (username && invitees.some(i => !i.isExisting && (i.username || '').toLowerCase() === userLower)) {
|
||||||
|
setError('Dieser Benutzername ist bereits in der Liste');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInvitees(prev => [...prev, {
|
setInvitees(prev => [...prev, {
|
||||||
email,
|
email,
|
||||||
username: undefined,
|
username: username || undefined,
|
||||||
roleIds: [...inviteeForm.roleIds],
|
roleIds: [...inviteeForm.roleIds],
|
||||||
isExisting: false,
|
isExisting: false,
|
||||||
}]);
|
}]);
|
||||||
|
|
@ -189,10 +200,6 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
const user = allSystemUsers.find(u => u.id === selectedExistingUserId);
|
const user = allSystemUsers.find(u => u.id === selectedExistingUserId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const email = (user.email || '').trim();
|
const email = (user.email || '').trim();
|
||||||
if (!email) {
|
|
||||||
setError('Dieser Benutzer hat keine E-Mail-Adresse');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (invitees.some(i => i.userId === user.id)) {
|
if (invitees.some(i => i.userId === user.id)) {
|
||||||
setError('Dieser Benutzer ist bereits in der Liste');
|
setError('Dieser Benutzer ist bereits in der Liste');
|
||||||
return;
|
return;
|
||||||
|
|
@ -232,8 +239,9 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
const results: DispatchResult[] = [];
|
const results: DispatchResult[] = [];
|
||||||
try {
|
try {
|
||||||
for (const inv of invitees) {
|
for (const inv of invitees) {
|
||||||
|
const emailTrim = (inv.email || '').trim();
|
||||||
const payload = {
|
const payload = {
|
||||||
email: inv.email,
|
...(emailTrim ? { email: emailTrim } : {}),
|
||||||
targetUsername: inv.username || undefined,
|
targetUsername: inv.username || undefined,
|
||||||
roleIds: inv.roleIds,
|
roleIds: inv.roleIds,
|
||||||
expiresInHours: EXPIRES_IN_HOURS,
|
expiresInHours: EXPIRES_IN_HOURS,
|
||||||
|
|
@ -244,14 +252,14 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
const result = await createInvitation(selectedMandate.id, payload);
|
const result = await createInvitation(selectedMandate.id, payload);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
results.push({
|
results.push({
|
||||||
email: inv.email,
|
email: emailTrim,
|
||||||
username: inv.username,
|
username: inv.username,
|
||||||
success: true,
|
success: true,
|
||||||
emailSent: result.data?.emailSent,
|
emailSent: result.data?.emailSent,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
results.push({
|
results.push({
|
||||||
email: inv.email,
|
email: emailTrim,
|
||||||
username: inv.username,
|
username: inv.username,
|
||||||
success: false,
|
success: false,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
|
|
@ -452,7 +460,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<div style={_cardStyle}>
|
<div style={_cardStyle}>
|
||||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>Einladungen hinzufügen</h3>
|
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>Einladungen hinzufügen</h3>
|
||||||
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', margin: '0 0 16px 0' }}>
|
<p style={{ color: 'var(--text-secondary)', fontSize: '13px', margin: '0 0 16px 0' }}>
|
||||||
E-Mail ist erforderlich. Neue Benutzer legen ihren Benutzernamen beim Annehmen der Einladung selbst fest. Sie können neue Benutzer per E-Mail oder bestehende Benutzer hinzufügen.
|
Für neue Benutzer: mindestens eine E-Mail <em>oder</em> ein Benutzername (vorgegeben). Ohne E-Mail wird kein Link per Mail versendet — der Einladungslink kann manuell geteilt werden. Bestehende Benutzer wählen Sie im zweiten Tab.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Add form: toggle email vs existing */}
|
{/* Add form: toggle email vs existing */}
|
||||||
|
|
@ -462,7 +470,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||||
onClick={() => setAddMode('email')}
|
onClick={() => setAddMode('email')}
|
||||||
>
|
>
|
||||||
Per E-Mail (neue Benutzer)
|
Neue Benutzer (E-Mail und/oder Benutzername)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={addMode === 'existing' ? styles.primaryButton : styles.secondaryButton}
|
className={addMode === 'existing' ? styles.primaryButton : styles.secondaryButton}
|
||||||
|
|
@ -476,7 +484,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
{addMode === 'email' ? (
|
{addMode === 'email' ? (
|
||||||
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
<div style={{ padding: '16px', background: 'var(--bg-secondary, #f8fafc)', borderRadius: '8px', marginBottom: '16px', display: 'grid', gap: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className={`${styles.formLabel} ${styles.required}`}>E-Mail *</label>
|
<label className={styles.formLabel}>E-Mail (optional)</label>
|
||||||
<input
|
<input
|
||||||
className={styles.formInput}
|
className={styles.formInput}
|
||||||
type="email"
|
type="email"
|
||||||
|
|
@ -484,8 +492,19 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
onChange={e => setInviteeForm(p => ({ ...p, email: e.target.value }))}
|
onChange={e => setInviteeForm(p => ({ ...p, email: e.target.value }))}
|
||||||
placeholder="beispiel@firma.com"
|
placeholder="beispiel@firma.com"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={styles.formLabel}>Benutzername (optional)</label>
|
||||||
|
<input
|
||||||
|
className={styles.formInput}
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
value={inviteeForm.username}
|
||||||
|
onChange={e => setInviteeForm(p => ({ ...p, username: e.target.value }))}
|
||||||
|
placeholder="z. B. vorname.nachname"
|
||||||
|
/>
|
||||||
<p style={{ fontSize: '11px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
<p style={{ fontSize: '11px', color: 'var(--text-secondary)', marginTop: '4px' }}>
|
||||||
Der Benutzername wird vom eingeladenen Benutzer beim Annehmen der Einladung festgelegt.
|
Mindestens eines der beiden Felder ausfüllen. Mit Benutzername muss der Eingeladene genau diesen Namen beim Annehmen verwenden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{roles.length > 0 && (
|
{roles.length > 0 && (
|
||||||
|
|
@ -497,7 +516,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
display: 'flex', alignItems: 'center', gap: '6px',
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
padding: '6px 12px', borderRadius: '6px',
|
padding: '6px 12px', borderRadius: '6px',
|
||||||
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
|
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
|
||||||
border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #3b82f6)' : 'var(--border-color, #e2e8f0)'}`,
|
border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #F25843)' : 'var(--border-color, #e2e8f0)'}`,
|
||||||
fontSize: '12px', cursor: 'pointer',
|
fontSize: '12px', cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -519,7 +538,10 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<button
|
<button
|
||||||
className={styles.primaryButton}
|
className={styles.primaryButton}
|
||||||
onClick={addInviteeByEmail}
|
onClick={addInviteeByEmail}
|
||||||
disabled={!inviteeForm.email.trim() || (roles.length > 0 && inviteeForm.roleIds.length === 0)}
|
disabled={
|
||||||
|
(!inviteeForm.email.trim() && !inviteeForm.username.trim())
|
||||||
|
|| (roles.length > 0 && inviteeForm.roleIds.length === 0)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Hinzufügen
|
Hinzufügen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -552,7 +574,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
display: 'flex', alignItems: 'center', gap: '6px',
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
padding: '6px 12px', borderRadius: '6px',
|
padding: '6px 12px', borderRadius: '6px',
|
||||||
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
|
background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)',
|
||||||
border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #3b82f6)' : 'var(--border-color, #e2e8f0)'}`,
|
border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #F25843)' : 'var(--border-color, #e2e8f0)'}`,
|
||||||
fontSize: '12px', cursor: 'pointer',
|
fontSize: '12px', cursor: 'pointer',
|
||||||
}}>
|
}}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -586,7 +608,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
|
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
|
||||||
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail</th>
|
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail / Benutzer</th>
|
||||||
<th style={{ textAlign: 'left', padding: '8px' }}>Benutzername</th>
|
<th style={{ textAlign: 'left', padding: '8px' }}>Benutzername</th>
|
||||||
<th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th>
|
<th style={{ textAlign: 'left', padding: '8px' }}>Rollen</th>
|
||||||
<th style={{ textAlign: 'left', padding: '8px' }}>Typ</th>
|
<th style={{ textAlign: 'left', padding: '8px' }}>Typ</th>
|
||||||
|
|
@ -596,14 +618,16 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<tbody>
|
<tbody>
|
||||||
{invitees.map((inv, idx) => (
|
{invitees.map((inv, idx) => (
|
||||||
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
||||||
<td style={{ padding: '8px' }}>{inv.email}</td>
|
<td style={{ padding: '8px' }}>{inv.email || '—'}</td>
|
||||||
<td style={{ padding: '8px', color: 'var(--text-secondary)' }}>{inv.isExisting ? inv.username : ''}</td>
|
<td style={{ padding: '8px', color: 'var(--text-secondary)' }}>
|
||||||
|
{inv.username || ''}
|
||||||
|
</td>
|
||||||
<td style={{ padding: '8px' }}>
|
<td style={{ padding: '8px' }}>
|
||||||
{inv.roleIds.length > 0
|
{inv.roleIds.length > 0
|
||||||
? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')
|
? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')
|
||||||
: '-'}
|
: '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px', fontSize: '12px' }}>{inv.isExisting ? 'Bestehend' : 'Neu'}</td>
|
<td style={{ padding: '8px', fontSize: '12px' }}>{inv.isExisting ? 'Bestehend' : 'Neu (Einladung)'}</td>
|
||||||
<td style={{ padding: '8px', textAlign: 'right' }}>
|
<td style={{ padding: '8px', textAlign: 'right' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeInvitee(idx)}
|
onClick={() => removeInvitee(idx)}
|
||||||
|
|
@ -654,7 +678,8 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||||
{invitees.map((inv, i) => (
|
{invitees.map((inv, i) => (
|
||||||
<li key={i} style={{ marginBottom: '4px' }}>
|
<li key={i} style={{ marginBottom: '4px' }}>
|
||||||
{inv.email}{inv.isExisting && inv.username ? ` (${inv.username})` : ''}
|
{[inv.email || null, inv.username ? `@${inv.username}` : null].filter(Boolean).join(' · ')
|
||||||
|
|| '—'}
|
||||||
{inv.roleIds.length > 0 && ` – ${roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')}`}
|
{inv.roleIds.length > 0 && ` – ${roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ')}`}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -680,7 +705,7 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
|
<tr style={{ borderBottom: '2px solid var(--border-color, #C5D9E8)' }}>
|
||||||
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail</th>
|
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail / Benutzer</th>
|
||||||
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
|
<th style={{ textAlign: 'left', padding: '8px' }}>Status</th>
|
||||||
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail gesendet</th>
|
<th style={{ textAlign: 'left', padding: '8px' }}>E-Mail gesendet</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -688,7 +713,11 @@ export const AdminInvitationWizardPage: React.FC = () => {
|
||||||
<tbody>
|
<tbody>
|
||||||
{dispatchResults.map((r, idx) => (
|
{dispatchResults.map((r, idx) => (
|
||||||
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
<tr key={idx} style={{ borderBottom: '1px solid var(--bg-secondary, #f1f5f9)' }}>
|
||||||
<td style={{ padding: '8px' }}>{r.email}{r.username ? ` (${r.username})` : ''}</td>
|
<td style={{ padding: '8px' }}>
|
||||||
|
{(r.email || '').trim() && r.username
|
||||||
|
? `${(r.email || '').trim()} (@${r.username})`
|
||||||
|
: (r.email || '').trim() || (r.username ? `@${r.username}` : '—')}
|
||||||
|
</td>
|
||||||
<td style={{ padding: '8px' }}>
|
<td style={{ padding: '8px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px', borderRadius: '4px', fontSize: '12px',
|
padding: '2px 8px', borderRadius: '4px', fontSize: '12px',
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import { FaSync, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaT
|
||||||
import { getApiBaseUrl } from '../../../config/config';
|
import { getApiBaseUrl } from '../../../config/config';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
|
/** Wenn false: keine neue ClickUp-Verbindung über diese Seite (Buttons inaktiv). */
|
||||||
|
const isClickupConnectionUiEnabled = false;
|
||||||
|
|
||||||
export const ConnectionsPage: React.FC = () => {
|
export const ConnectionsPage: React.FC = () => {
|
||||||
// Use the consolidated hook
|
// Use the consolidated hook
|
||||||
const {
|
const {
|
||||||
|
|
@ -190,8 +193,9 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle create ClickUp connection
|
// Handle create ClickUp connection (UI kann per Flag abgeschaltet sein)
|
||||||
const handleCreateClickup = async () => {
|
const handleCreateClickup = async () => {
|
||||||
|
if (!isClickupConnectionUiEnabled) return;
|
||||||
try {
|
try {
|
||||||
await createClickupConnectionAndAuth();
|
await createClickupConnectionAndAuth();
|
||||||
refetch();
|
refetch();
|
||||||
|
|
@ -220,7 +224,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes for edit modal
|
// Form attributes for edit modal
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked'];
|
const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked'];
|
||||||
return (attributes || [])
|
return (attributes || [])
|
||||||
.filter(attr => !excludedFields.includes(attr.name));
|
.filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
@ -244,7 +248,10 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Verbindungen</h1>
|
<h1 className={styles.pageTitle}>Verbindungen</h1>
|
||||||
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten (Google, Microsoft, ClickUp)</p>
|
<p className={styles.pageSubtitle}>
|
||||||
|
Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft
|
||||||
|
{isClickupConnectionUiEnabled ? ', ClickUp' : ''})
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -278,14 +285,17 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<FaMicrosoft /> Microsoft
|
<FaMicrosoft /> Microsoft
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isClickupConnectionUiEnabled && (
|
||||||
className={styles.clickupButton}
|
<button
|
||||||
onClick={handleCreateClickup}
|
type="button"
|
||||||
disabled={isConnecting}
|
className={styles.clickupButton}
|
||||||
title="ClickUp-Konto verbinden"
|
onClick={handleCreateClickup}
|
||||||
>
|
disabled={isConnecting}
|
||||||
<FaTasks /> ClickUp
|
title="ClickUp-Konto verbinden"
|
||||||
</button>
|
>
|
||||||
|
<FaTasks /> ClickUp
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -302,7 +312,9 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
<FaPlug className={styles.emptyIcon} />
|
<FaPlug className={styles.emptyIcon} />
|
||||||
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
|
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
|
||||||
<p className={styles.emptyDescription}>
|
<p className={styles.emptyDescription}>
|
||||||
Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.
|
{isClickupConnectionUiEnabled
|
||||||
|
? 'Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.'
|
||||||
|
: 'Verbinden Sie Ihr Google- oder Microsoft-Konto, um loszulegen.'}
|
||||||
</p>
|
</p>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
|
|
@ -320,13 +332,16 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<FaMicrosoft /> Mit Microsoft verbinden
|
<FaMicrosoft /> Mit Microsoft verbinden
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isClickupConnectionUiEnabled && (
|
||||||
className={styles.clickupButton}
|
<button
|
||||||
onClick={handleCreateClickup}
|
type="button"
|
||||||
disabled={isConnecting}
|
className={styles.clickupButton}
|
||||||
>
|
onClick={handleCreateClickup}
|
||||||
<FaTasks /> Mit ClickUp verbinden
|
disabled={isConnecting}
|
||||||
</button>
|
>
|
||||||
|
<FaTasks /> Mit ClickUp verbinden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -360,7 +375,9 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
icon: <FaLink />,
|
icon: <FaLink />,
|
||||||
onClick: handleConnect,
|
onClick: handleConnect,
|
||||||
title: 'Verbinden',
|
title: 'Verbinden',
|
||||||
visible: (row: Connection) => row.status !== 'active',
|
visible: (row: Connection) =>
|
||||||
|
row.status !== 'active' &&
|
||||||
|
(isClickupConnectionUiEnabled || row.authority !== 'clickup'),
|
||||||
loading: () => isConnecting,
|
loading: () => isConnecting,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { FileNode } from '../../components/FolderTree/FolderTree';
|
||||||
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||||
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import styles from '../admin/Admin.module.css';
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
interface UserFile {
|
interface UserFile {
|
||||||
|
|
@ -31,6 +32,7 @@ interface UserFile {
|
||||||
export const FilesPage: React.FC = () => {
|
export const FilesPage: React.FC = () => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -142,7 +144,7 @@ export const FilesPage: React.FC = () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
cols.push({
|
cols.push({
|
||||||
key: '_createdBy',
|
key: 'sysCreatedBy',
|
||||||
label: 'Created By',
|
label: 'Created By',
|
||||||
type: 'text' as any,
|
type: 'text' as any,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
|
@ -223,11 +225,11 @@ export const FilesPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleNewFolder = useCallback(async () => {
|
const _handleNewFolder = useCallback(async () => {
|
||||||
const name = prompt('Neuer Ordnername:');
|
const name = await promptInput('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' });
|
||||||
if (name?.trim()) {
|
if (name?.trim()) {
|
||||||
await handleCreateFolder(name.trim(), selectedFolderId);
|
await handleCreateFolder(name.trim(), selectedFolderId);
|
||||||
}
|
}
|
||||||
}, [handleCreateFolder, selectedFolderId]);
|
}, [handleCreateFolder, selectedFolderId, promptInput]);
|
||||||
|
|
||||||
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
|
||||||
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
const isInSelection = selectedFiles.some(f => f.id === row.id);
|
||||||
|
|
@ -289,7 +291,7 @@ export const FilesPage: React.FC = () => {
|
||||||
}, [selectedFolderId, _tableRefetch]);
|
}, [selectedFolderId, _tableRefetch]);
|
||||||
|
|
||||||
const formAttributes = useMemo(() => {
|
const formAttributes = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source'];
|
const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source'];
|
||||||
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
@ -379,7 +381,7 @@ export const FilesPage: React.FC = () => {
|
||||||
style={{
|
style={{
|
||||||
width: 6,
|
width: 6,
|
||||||
cursor: 'col-resize',
|
cursor: 'col-resize',
|
||||||
background: isDragging ? 'var(--color-primary, #1976d2)' : 'transparent',
|
background: isDragging ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.2))' : 'transparent',
|
||||||
transition: isDragging ? 'none' : 'background 0.15s',
|
transition: isDragging ? 'none' : 'background 0.15s',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
|
|
@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PromptDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -185,6 +161,49 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({ settings, onSave, loadi
|
||||||
</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"
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
|
@ -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,18 +322,7 @@ 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>;
|
||||||
|
|
@ -361,15 +332,18 @@ const AccountsOverview: React.FC<AccountsOverviewProps> = ({ accounts, users, lo
|
||||||
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
return <div className={styles.noData}>Keine Konten vorhanden</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (poolAccounts.length === 0) {
|
||||||
|
return <div className={styles.noData}>Kein Mandanten-Konto vorhanden</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.adminSection}>
|
<div className={styles.adminSection}>
|
||||||
<h3>Konten</h3>
|
<h3>Konten</h3>
|
||||||
<div className={styles.accountsGrid}>
|
<div className={styles.accountsGrid}>
|
||||||
{accounts.map((account) => (
|
{poolAccounts.map((account) => (
|
||||||
<div key={account.id} className={styles.accountCard}>
|
<div key={account.id} className={styles.accountCard}>
|
||||||
<h4>{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}</h4>
|
<h4>Mandanten-Konto</h4>
|
||||||
<div className={styles.accountInfo}>
|
<div className={styles.accountInfo}>
|
||||||
{account.userId && <span>User: {_userNameMap.get(account.userId) || account.userId}</span>}
|
|
||||||
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
<span>Guthaben: <strong>{formatCurrency(account.balance)}</strong></span>
|
||||||
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
<span>Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -631,6 +605,7 @@ export const BillingAdmin: React.FC = () => {
|
||||||
const [adminTab, setAdminTab] = useState<AdminTabType>(
|
const [adminTab, setAdminTab] = useState<AdminTabType>(
|
||||||
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
|
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
|
||||||
);
|
);
|
||||||
|
const [subscriptionTabKey, setSubscriptionTabKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
|
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
|
||||||
|
|
@ -701,7 +676,7 @@ export const BillingAdmin: React.FC = () => {
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
|
backgroundColor: isActive ? 'var(--primary-color, #F25843)' : 'transparent',
|
||||||
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
@ -762,7 +737,7 @@ export const BillingAdmin: React.FC = () => {
|
||||||
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
|
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
|
||||||
Guthaben
|
Guthaben
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
|
<button onClick={() => { setAdminTab('subscription'); setSubscriptionTabKey(k => k + 1); }} style={_tabStyle(adminTab === 'subscription')}>
|
||||||
Abonnement
|
Abonnement
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
|
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
|
||||||
|
|
@ -782,9 +757,6 @@ export const BillingAdmin: React.FC = () => {
|
||||||
<>
|
<>
|
||||||
{isSysAdmin && (
|
{isSysAdmin && (
|
||||||
<CreditAdder
|
<CreditAdder
|
||||||
settings={settings}
|
|
||||||
accounts={accounts}
|
|
||||||
users={users}
|
|
||||||
onAddCredit={_handleAddCredit}
|
onAddCredit={_handleAddCredit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -798,7 +770,7 @@ export const BillingAdmin: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{adminTab === 'subscription' && (
|
{adminTab === 'subscription' && (
|
||||||
<SubscriptionTab mandateId={selectedMandateId} />
|
<SubscriptionTab key={subscriptionTabKey} mandateId={selectedMandateId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{adminTab === 'transactions' && (
|
{adminTab === 'transactions' && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -157,7 +128,7 @@ const TabNav: React.FC<TabNavProps> = ({ activeTab, onTabChange }) => {
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
|
backgroundColor: isActive ? 'var(--primary-color, #F25843)' : 'transparent',
|
||||||
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
||||||
fontWeight: isActive ? 600 : 400,
|
fontWeight: isActive ? 600 : 400,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
@ -329,10 +300,13 @@ 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) => {
|
||||||
|
|
@ -645,12 +613,6 @@ export const BillingDataView: React.FC = () => {
|
||||||
? 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 (
|
||||||
<>
|
<>
|
||||||
{/* Balance Cards - own balances */}
|
{/* Balance Cards - own balances */}
|
||||||
|
|
@ -666,35 +628,60 @@ export const BillingDataView: React.FC = () => {
|
||||||
<BalanceCard
|
<BalanceCard
|
||||||
key={balance.mandateId}
|
key={balance.mandateId}
|
||||||
balance={balance}
|
balance={balance}
|
||||||
onCheckout={_handleCheckout}
|
onOpenMandateAdmin={_openMandateBillingAdmin}
|
||||||
checkoutLoading={checkoutLoading}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* All User Balance Cards (mandate/all scope) */}
|
{/* Storage quick info */}
|
||||||
{filteredUserBalances.length > 0 && (
|
{!storageLoading && storageData.length > 0 && (
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Benutzer-Guthaben</h2>
|
<h2 className={styles.sectionTitle}>Speicher</h2>
|
||||||
{allUserBalancesLoading ? (
|
<div className={styles.balanceGrid}>
|
||||||
<div className={styles.loadingPlaceholder}>Lade Benutzer-Guthaben...</div>
|
{storageData.map((sv) => {
|
||||||
) : (
|
const pct = sv.percentUsed ?? 0;
|
||||||
<div className={styles.balanceGrid}>
|
const barColor = pct >= 90
|
||||||
{filteredUserBalances.map((ub, idx) => (
|
? 'var(--color-error, #ef4444)'
|
||||||
<div key={`${ub.userId}-${ub.mandateId}-${idx}`} className={styles.balanceCard}>
|
: pct >= 70
|
||||||
<div className={styles.balanceHeader}>
|
? 'var(--color-warning, #f59e0b)'
|
||||||
<h3 className={styles.mandateName}>{ub.userName || ub.userId?.slice(0, 8)}</h3>
|
: 'var(--primary-color, #F25843)';
|
||||||
<span className={styles.billingModel}>{ub.mandateName}</span>
|
return (
|
||||||
</div>
|
<div key={sv.mandateId} className={styles.balanceCard}>
|
||||||
<div className={styles.balanceAmount}>
|
<h3 className={styles.mandateName}>{sv.mandateName}</h3>
|
||||||
{_formatCurrency(ub.balance || 0)}
|
<div className={styles.balanceAmount} style={{ fontSize: '1.3rem' }}>
|
||||||
|
{formatBinaryDataSizeFromMebibytes(sv.usedMB)}
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', marginLeft: '6px' }}>
|
||||||
|
/ {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{sv.maxDataVolumeMB != null && (
|
||||||
|
<div style={{
|
||||||
|
height: '6px',
|
||||||
|
background: 'var(--bg-secondary, #2a2a2a)',
|
||||||
|
borderRadius: '3px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '10px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.min(pct, 100)}%`,
|
||||||
|
background: barColor,
|
||||||
|
borderRadius: '3px',
|
||||||
|
minWidth: pct > 0 ? '3px' : '0',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sv.warning && (
|
||||||
|
<div className={styles.warningBadge} style={{ marginTop: '8px' }}>
|
||||||
|
Speicher knapp
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -716,18 +703,104 @@ export const BillingDataView: React.FC = () => {
|
||||||
{/* Tab: Statistik (Dashboard) */}
|
{/* Tab: Statistik (Dashboard) */}
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
{activeTab === 'statistics' && (
|
{activeTab === 'statistics' && (
|
||||||
<section className={styles.section}>
|
<>
|
||||||
<FormGeneratorReport
|
{/* Storage volume section */}
|
||||||
title="Nutzungsstatistik"
|
<section className={styles.section}>
|
||||||
subtitle="Detaillierte Analyse der AI-Nutzung"
|
<div className={styles.statisticsChart}>
|
||||||
periodSelector={periodSelectorConfig}
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '1.1rem', fontWeight: 600 }}>
|
||||||
onFilterChange={_handleStatsFilterChange}
|
Speicherverbrauch
|
||||||
loading={statsLoading}
|
</h3>
|
||||||
sections={statisticsSections}
|
{storageLoading ? (
|
||||||
noDataMessage="Keine Statistiken verfügbar"
|
<div className={styles.loadingPlaceholder}>Lade Speicherdaten...</div>
|
||||||
currencyCode="CHF"
|
) : storageData.length === 0 ? (
|
||||||
/>
|
<div className={styles.noData}>Keine Speicherdaten verfügbar</div>
|
||||||
</section>
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{storageData.map((sv) => {
|
||||||
|
const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB);
|
||||||
|
const maxLabel = sv.maxDataVolumeMB != null
|
||||||
|
? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB)
|
||||||
|
: 'unbegrenzt';
|
||||||
|
const pct = sv.percentUsed ?? 0;
|
||||||
|
const barColor = pct >= 90
|
||||||
|
? 'var(--color-error, #ef4444)'
|
||||||
|
: pct >= 70
|
||||||
|
? 'var(--color-warning, #f59e0b)'
|
||||||
|
: 'var(--primary-color, #F25843)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={sv.mandateId} style={{
|
||||||
|
background: 'var(--bg-secondary, #2a2a2a)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '14px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
|
||||||
|
{sv.mandateName}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #888)', fontFamily: 'monospace' }}>
|
||||||
|
{usedLabel} / {maxLabel}
|
||||||
|
{sv.percentUsed != null && (
|
||||||
|
<span style={{ marginLeft: '8px', color: barColor, fontWeight: 600 }}>
|
||||||
|
({pct.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sv.maxDataVolumeMB != null && (
|
||||||
|
<div style={{
|
||||||
|
height: '10px',
|
||||||
|
background: 'var(--surface-color, #1e1e1e)',
|
||||||
|
borderRadius: '5px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${Math.min(pct, 100)}%`,
|
||||||
|
background: barColor,
|
||||||
|
borderRadius: '5px',
|
||||||
|
transition: 'width 0.4s ease',
|
||||||
|
minWidth: pct > 0 ? '4px' : '0',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
color: 'var(--text-secondary, #888)',
|
||||||
|
}}>
|
||||||
|
<span>Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)}</span>
|
||||||
|
<span>RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* AI usage statistics */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<FormGeneratorReport
|
||||||
|
title="Nutzungsstatistik"
|
||||||
|
subtitle="Detaillierte Analyse der AI-Nutzung"
|
||||||
|
periodSelector={periodSelectorConfig}
|
||||||
|
onFilterChange={_handleStatsFilterChange}
|
||||||
|
loading={statsLoading}
|
||||||
|
sections={statisticsSections}
|
||||||
|
noDataMessage="Keine Statistiken verfügbar"
|
||||||
|
currencyCode="CHF"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ================================================================ */}
|
{/* ================================================================ */}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -43,7 +44,7 @@ const _statusLabel: Record<string, { label: string; color: string }> = {
|
||||||
PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
||||||
SCHEDULED: { label: 'Geplant', color: '#8b5cf6' },
|
SCHEDULED: { label: 'Geplant', color: '#8b5cf6' },
|
||||||
ACTIVE: { label: 'Aktiv', color: '#22c55e' },
|
ACTIVE: { label: 'Aktiv', color: '#22c55e' },
|
||||||
TRIALING: { label: 'Testphase', color: '#3b82f6' },
|
TRIALING: { label: 'Testphase', color: '#38bdf8' },
|
||||||
PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
||||||
EXPIRED: { label: 'Abgelaufen', color: '#6b7280' },
|
EXPIRED: { label: 'Abgelaufen', color: '#6b7280' },
|
||||||
};
|
};
|
||||||
|
|
@ -54,6 +55,9 @@ const _periodLabel: Record<string, string> = {
|
||||||
NONE: '—',
|
NONE: '—',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */
|
||||||
|
const storageOverageChfPerGbMonth = 0.5;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plan Card
|
// Plan Card
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -71,13 +75,13 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
border: isCurrent ? '2px solid var(--color-primary, #3b82f6)' : '1px solid var(--color-border, #333)',
|
border: isCurrent ? '2px solid var(--primary-color, #F25843)' : '1px solid var(--color-border, var(--border-color, #333))',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '1.25rem',
|
padding: '1.25rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
background: isCurrent ? 'rgba(59,130,246,0.06)' : 'var(--color-surface, #1a1a2e)',
|
background: isCurrent ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))' : 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
|
||||||
minWidth: 220,
|
minWidth: 220,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
|
@ -85,12 +89,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
||||||
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
|
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
|
||||||
}}>Aktuell</span>
|
}}>Aktuell</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: 0 }}>
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>
|
||||||
{_t(plan.description)}
|
{_t(plan.description)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -98,6 +102,19 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
<div style={{ fontSize: '0.85rem' }}>
|
<div style={{ fontSize: '0.85rem' }}>
|
||||||
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||||
|
<div style={{ marginTop: '0.35rem', color: 'var(--text-secondary)' }}>
|
||||||
|
AI-Budget (inkl.): <strong>{_formatCurrency(plan.budgetAiCHF ?? 0)}</strong> / Periode
|
||||||
|
{' · '}
|
||||||
|
Speicher (inkl.):{' '}
|
||||||
|
<strong>
|
||||||
|
{plan.maxDataVolumeMB == null
|
||||||
|
? 'unbegrenzt'
|
||||||
|
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
|
||||||
|
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -106,6 +123,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
{plan.trialDays} Tage kostenlos
|
{plan.trialDays} Tage kostenlos
|
||||||
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
||||||
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
||||||
|
{(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && (
|
||||||
|
<>
|
||||||
|
{plan.maxDataVolumeMB != null && (
|
||||||
|
<> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}</>
|
||||||
|
)}
|
||||||
|
{(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}</>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -115,7 +140,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activa
|
||||||
disabled={!!activatingPlanKey}
|
disabled={!!activatingPlanKey}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
|
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
|
||||||
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
|
background: 'var(--primary-color, #F25843)', color: '#fff', fontWeight: 600,
|
||||||
cursor: activatingPlanKey ? 'wait' : 'pointer',
|
cursor: activatingPlanKey ? 'wait' : 'pointer',
|
||||||
opacity: activatingPlanKey ? 0.6 : 1,
|
opacity: activatingPlanKey ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
|
|
@ -152,15 +177,15 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
border: '1px solid var(--color-border, #333)',
|
border: '1px solid var(--color-border, var(--border-color, #333))',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '1.25rem',
|
padding: '1.25rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
background: 'var(--color-surface, #1a1a2e)',
|
background: 'var(--surface-color, var(--bg-secondary, #1a1a2e))',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
|
@ -204,7 +229,7 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
||||||
|
|
||||||
{!isPending && !isScheduled && (
|
{!isPending && !isScheduled && (
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '0.85rem', color: 'var(--text-secondary, #888)',
|
fontSize: '0.85rem', color: 'var(--text-secondary)',
|
||||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
|
||||||
}}>
|
}}>
|
||||||
<span>Gestartet: {_formatDate(sub.startedAt)}</span>
|
<span>Gestartet: {_formatDate(sub.startedAt)}</span>
|
||||||
|
|
@ -214,6 +239,20 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
||||||
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
||||||
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
||||||
)}
|
)}
|
||||||
|
{plan && (
|
||||||
|
<>
|
||||||
|
<span>AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode</span>
|
||||||
|
<span>
|
||||||
|
Speicher (inkl.):{' '}
|
||||||
|
{plan.maxDataVolumeMB == null
|
||||||
|
? 'unbegrenzt'
|
||||||
|
: formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)}
|
||||||
|
</span>
|
||||||
|
<span style={{ gridColumn: '1 / -1' }}>
|
||||||
|
Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark)
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -224,7 +263,7 @@ const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onRea
|
||||||
disabled={reactivating}
|
disabled={reactivating}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 14px', borderRadius: '6px', border: 'none',
|
padding: '6px 14px', borderRadius: '6px', border: 'none',
|
||||||
background: 'var(--color-primary, #3b82f6)', color: '#fff',
|
background: 'var(--primary-color, #F25843)', color: '#fff',
|
||||||
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -311,14 +350,21 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
|
||||||
|
|
||||||
if (sessionId && !verifyCalledRef.current) {
|
if (sessionId && !verifyCalledRef.current) {
|
||||||
verifyCalledRef.current = true;
|
verifyCalledRef.current = true;
|
||||||
verifyCheckout(sessionId)
|
const _pollUntilActive = async (retries = 5, delayMs = 2000) => {
|
||||||
.then((result) => {
|
try {
|
||||||
|
const result = await verifyCheckout(sessionId);
|
||||||
if (result.status === 'activated') {
|
if (result.status === 'activated') {
|
||||||
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
|
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
|
||||||
setJustPaid(false);
|
setJustPaid(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
})
|
} catch { /* handled below via retry */ }
|
||||||
.catch(() => {});
|
if (retries > 0) {
|
||||||
|
await new Promise(r => setTimeout(r, delayMs));
|
||||||
|
await _pollUntilActive(retries - 1, delayMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_pollUntilActive();
|
||||||
}
|
}
|
||||||
} else if (params.get('canceled') === 'true') {
|
} else if (params.get('canceled') === 'true') {
|
||||||
setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' });
|
setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' });
|
||||||
|
|
@ -358,7 +404,7 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
|
||||||
{
|
{
|
||||||
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
||||||
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
||||||
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
|
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen',
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -397,9 +443,9 @@ export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) =
|
||||||
{checkoutMessage && (
|
{checkoutMessage && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
|
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
|
||||||
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(59,130,246,0.12)',
|
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'var(--primary-dark-bg, rgba(242, 88, 67, 0.12))',
|
||||||
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6'}`,
|
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-color, #F25843)'}`,
|
||||||
color: checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6',
|
color: checkoutMessage.type === 'success' ? '#22c55e' : 'var(--primary-light, #F25843)',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
}}>
|
}}>
|
||||||
{checkoutMessage.text}
|
{checkoutMessage.text}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,7 @@ const OutputCard: React.FC<{
|
||||||
run: CompletedRun;
|
run: CompletedRun;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
}> = ({ run }) => {
|
}> = ({ run }) => {
|
||||||
const ts = run._modifiedAt ?? run._createdAt ?? 0;
|
const ts = run.sysModifiedAt ?? run.sysCreatedAt ?? 0;
|
||||||
const files: Array<{ name: string; fileId: string }> = [];
|
const files: Array<{ name: string; fileId: string }> = [];
|
||||||
const nodeOutputs = run.nodeOutputs ?? {};
|
const nodeOutputs = run.nodeOutputs ?? {};
|
||||||
for (const [, out] of Object.entries(nodeOutputs)) {
|
for (const [, out] of Object.entries(nodeOutputs)) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,56 @@
|
||||||
|
/* Outer flex layout: UDB sidebar + main dossier */
|
||||||
|
.dossierLayout {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.2s, min-width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbSidebarCollapsed {
|
||||||
|
width: 36px;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbToggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.udbToggle:hover {
|
||||||
|
background: var(--bg-hover, #f5f5f5);
|
||||||
|
color: var(--primary-color, #F25843);
|
||||||
|
}
|
||||||
|
|
||||||
.dossier {
|
.dossier {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 140px);
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,6 +406,115 @@
|
||||||
.typingDots { animation: blink 1.4s infinite both; }
|
.typingDots { animation: blink 1.4s infinite both; }
|
||||||
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } }
|
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } }
|
||||||
|
|
||||||
|
.agentActivityPanel {
|
||||||
|
margin: 0 1rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityHeader {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
background: var(--bg-hover, #f8f8f8);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityTitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityStatus {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityChevron {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityEmpty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #ededed);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityItemHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityToolName {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityBadge {
|
||||||
|
padding: 0.12rem 0.42rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityBadgeRunning {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityBadgeSuccess {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityBadgeError {
|
||||||
|
background: #fde8e8;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agentActivityMeta {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* Input Area */
|
/* Input Area */
|
||||||
.inputArea {
|
.inputArea {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,125 @@
|
||||||
/**
|
/**
|
||||||
* CommCoach Dossier View (Main View)
|
* CommCoach Dossier View (Main View)
|
||||||
*
|
*
|
||||||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents.
|
* Unified view per context: Coaching session, Tasks, Sessions history, Scores.
|
||||||
* Voice first, always with text fallback.
|
* Voice first, always with text fallback.
|
||||||
|
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||||||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||||
import api from '../../../api';
|
|
||||||
import {
|
import {
|
||||||
getDossierExportUrl, getSessionExportUrl,
|
getDossierExportUrl, getSessionExportUrl,
|
||||||
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
|
|
||||||
getScoreHistoryApi, getPersonasApi,
|
getScoreHistoryApi, getPersonasApi,
|
||||||
type CoachingDocument, type CoachingPersona,
|
type CoachingPersona,
|
||||||
|
type SendMessageOptions,
|
||||||
} from '../../../api/commcoachApi';
|
} from '../../../api/commcoachApi';
|
||||||
|
import api from '../../../api';
|
||||||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
|
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||||
|
import { ProviderMultiSelect, _defaultProviderSelection } from '../../../components/ProviderSelector';
|
||||||
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||||
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
import styles from './CommcoachDossierView.module.css';
|
import styles from './CommcoachDossierView.module.css';
|
||||||
import { useVoiceController } from './useVoiceController';
|
import { useVoiceController } from './useVoiceController';
|
||||||
|
|
||||||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
|
interface WorkspaceFileInfo {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
}
|
||||||
|
interface DataSourceInfo {
|
||||||
|
id: string;
|
||||||
|
connectionId: string;
|
||||||
|
sourceType: string;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
interface FeatureDataSourceInfo {
|
||||||
|
id: string;
|
||||||
|
featureInstanceId: string;
|
||||||
|
featureCode: string;
|
||||||
|
tableName: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const CommcoachDossierView: React.FC = () => {
|
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||||||
const coach = useCommcoach();
|
|
||||||
|
interface CommcoachDossierViewProps {
|
||||||
|
persistentInstanceId?: string;
|
||||||
|
persistentMandateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({
|
||||||
|
persistentInstanceId,
|
||||||
|
persistentMandateId,
|
||||||
|
}) => {
|
||||||
|
const routeInstanceId = useInstanceId();
|
||||||
|
const routeMandateId = useMandateId();
|
||||||
|
const instanceId = persistentInstanceId || routeInstanceId;
|
||||||
|
const mandateId = persistentMandateId || routeMandateId;
|
||||||
|
const coach = useCommcoach(instanceId);
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||||||
const [showNewContext, setShowNewContext] = useState(false);
|
const [showNewContext, setShowNewContext] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState('');
|
const [newTitle, setNewTitle] = useState('');
|
||||||
const [newDescription, setNewDescription] = useState('');
|
const [newDescription, setNewDescription] = useState('');
|
||||||
const [newCategory, setNewCategory] = useState('custom');
|
const [newCategory, setNewCategory] = useState('custom');
|
||||||
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||||||
|
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||||||
|
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||||
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
|
||||||
|
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
|
||||||
|
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
|
||||||
|
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
||||||
|
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
|
||||||
|
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
|
||||||
|
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection);
|
||||||
|
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||||
|
const [showFilePicker, setShowFilePicker] = useState(false);
|
||||||
|
const [showAgentActivity, setShowAgentActivity] = useState(true);
|
||||||
|
|
||||||
|
const _udbContext: UdbContext | null = instanceId
|
||||||
|
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
||||||
|
: null;
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const sendMessageRef = useRef(coach.sendMessage);
|
const sendMessageRef = useRef(coach.sendMessage);
|
||||||
sendMessageRef.current = coach.sendMessage;
|
sendMessageRef.current = coach.sendMessage;
|
||||||
|
|
||||||
const voice = useVoiceController({
|
const attachedFileIdsRef = useRef(attachedFileIds);
|
||||||
onFinalText: (text) => sendMessageRef.current(text),
|
attachedFileIdsRef.current = attachedFileIds;
|
||||||
});
|
const attachedDsIdsRef = useRef(attachedDsIds);
|
||||||
|
attachedDsIdsRef.current = attachedDsIds;
|
||||||
|
const attachedFdsIdsRef = useRef(attachedFdsIds);
|
||||||
|
attachedFdsIdsRef.current = attachedFdsIds;
|
||||||
|
const providerSelRef = useRef(providerSelection);
|
||||||
|
providerSelRef.current = providerSelection;
|
||||||
|
|
||||||
// #region agent log
|
const voice = useVoiceController({
|
||||||
const debugLogsRef = useRef<string[]>([]);
|
onFinalText: (text) => {
|
||||||
const [debugVisible, setDebugVisible] = useState(false);
|
const opts: SendMessageOptions = {};
|
||||||
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]);
|
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
|
||||||
const _dlog = useCallback((tag: string, info?: string) => {
|
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
|
||||||
const t = new Date();
|
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
|
||||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2,'0')}.${String(t.getMilliseconds()).padStart(3,'0')}`;
|
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
|
||||||
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
|
if (allowed) opts.allowedProviders = allowed;
|
||||||
debugLogsRef.current.push(entry);
|
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
|
||||||
if (debugLogsRef.current.length > 80) debugLogsRef.current.shift();
|
},
|
||||||
}, []);
|
});
|
||||||
useEffect(() => { (window as any).__dlog = _dlog; return () => { delete (window as any).__dlog; }; }, [_dlog]);
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
coach.onTtsEventRef.current = (event: TtsEvent) => {
|
coach.onTtsEventRef.current = (event: TtsEvent) => {
|
||||||
|
|
@ -82,27 +138,14 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||||||
|
|
||||||
// Load documents, scores, personas when context changes
|
// Load scores, personas when context changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !coach.selectedContextId) return;
|
if (!instanceId || !coach.selectedContextId) return;
|
||||||
getDocumentsApi(request, instanceId, coach.selectedContextId)
|
|
||||||
.then(d => setDocuments(d))
|
|
||||||
.catch(() => {});
|
|
||||||
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
||||||
.then(h => setScoreHistory(h))
|
.then(h => setScoreHistory(h))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [instanceId, request, coach.selectedContextId]);
|
}, [instanceId, request, coach.selectedContextId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
coach.onDocumentCreatedRef.current = (doc) => {
|
|
||||||
setDocuments(prev => {
|
|
||||||
if (prev.some(d => d.id === doc.id)) return prev;
|
|
||||||
return [doc, ...prev];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return () => { coach.onDocumentCreatedRef.current = null; };
|
|
||||||
}, [coach.onDocumentCreatedRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
getPersonasApi(request, instanceId)
|
getPersonasApi(request, instanceId)
|
||||||
|
|
@ -110,6 +153,23 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [instanceId, request]);
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const _refreshWorkspaceAssets = useCallback(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
|
||||||
|
api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
|
||||||
|
api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_refreshWorkspaceAssets();
|
||||||
|
}, [_refreshWorkspaceAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const _handleFileUploaded = () => _refreshWorkspaceAssets();
|
||||||
|
window.addEventListener('fileUploaded', _handleFileUploaded);
|
||||||
|
return () => window.removeEventListener('fileUploaded', _handleFileUploaded);
|
||||||
|
}, [_refreshWorkspaceAssets]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'coaching' || !coach.session) {
|
if (activeTab !== 'coaching' || !coach.session) {
|
||||||
voice.deactivate();
|
voice.deactivate();
|
||||||
|
|
@ -118,14 +178,51 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [activeTab, coach.session?.id, voice]);
|
}, [activeTab, coach.session?.id, voice]);
|
||||||
|
|
||||||
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
|
useEffect(() => {
|
||||||
|
coach.onDocumentCreatedRef.current = () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
|
||||||
|
};
|
||||||
|
return () => {
|
||||||
|
coach.onDocumentCreatedRef.current = null;
|
||||||
|
};
|
||||||
|
}, [coach, _refreshWorkspaceAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (coach.agentToolCalls.length > 0) {
|
||||||
|
setShowAgentActivity(true);
|
||||||
|
}
|
||||||
|
}, [coach.agentToolCalls.length]);
|
||||||
|
|
||||||
|
const handleStopTts = useCallback(() => {
|
||||||
|
coach.stopTts();
|
||||||
|
voice.ttsStopped();
|
||||||
|
}, [coach, voice]);
|
||||||
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||||
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
||||||
await coach.sendMessage(coach.inputValue);
|
const opts: SendMessageOptions = {};
|
||||||
}, [coach]);
|
if (attachedFileIds.length) opts.fileIds = attachedFileIds;
|
||||||
|
if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
|
||||||
|
if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
|
||||||
|
const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
|
||||||
|
if (allowed) opts.allowedProviders = allowed;
|
||||||
|
await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
|
||||||
|
setAttachedFileIds([]);
|
||||||
|
setShowSourcePicker(false);
|
||||||
|
setShowFilePicker(false);
|
||||||
|
}, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
|
||||||
|
|
||||||
|
const _toggleFile = useCallback((fileId: string) => {
|
||||||
|
setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
|
||||||
|
}, []);
|
||||||
|
const _toggleDs = useCallback((dsId: string) => {
|
||||||
|
setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
|
||||||
|
}, []);
|
||||||
|
const _toggleFds = useCallback((fdsId: string) => {
|
||||||
|
setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||||||
|
|
@ -144,46 +241,6 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
coach.selectContext(contextId, { skipSessionResume: true });
|
coach.selectContext(contextId, { skipSessionResume: true });
|
||||||
}, [coach]);
|
}, [coach]);
|
||||||
|
|
||||||
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file || !instanceId || !coach.selectedContextId) return;
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
|
|
||||||
setDocuments(prev => [doc, ...prev]);
|
|
||||||
} catch { /* upload failed */ } finally {
|
|
||||||
setUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
}, [instanceId, coach.selectedContextId]);
|
|
||||||
|
|
||||||
const handleDeleteDocument = useCallback(async (docId: string) => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
|
||||||
await deleteDocumentApi(request, instanceId, docId);
|
|
||||||
setDocuments(prev => prev.filter(d => d.id !== docId));
|
|
||||||
} catch { /* delete failed */ }
|
|
||||||
}, [instanceId, request]);
|
|
||||||
|
|
||||||
const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => {
|
|
||||||
if (!doc.fileRef) return;
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/files/${doc.fileRef}/download`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
});
|
|
||||||
const url = window.URL.createObjectURL(response.data);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = doc.fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Download failed:', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddTask = useCallback(async () => {
|
const handleAddTask = useCallback(async () => {
|
||||||
if (!newTaskTitle.trim()) return;
|
if (!newTaskTitle.trim()) return;
|
||||||
await coach.addTask(newTaskTitle);
|
await coach.addTask(newTaskTitle);
|
||||||
|
|
@ -195,7 +252,30 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dossier}>
|
<div className={styles.dossierLayout}>
|
||||||
|
{/* UDB Sidebar */}
|
||||||
|
{_udbContext && (
|
||||||
|
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||||||
|
<button
|
||||||
|
className={styles.udbToggle}
|
||||||
|
onClick={() => setUdbCollapsed(v => !v)}
|
||||||
|
title={udbCollapsed ? 'Seitenleiste einblenden' : 'Seitenleiste ausblenden'}
|
||||||
|
>
|
||||||
|
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
||||||
|
</button>
|
||||||
|
{!udbCollapsed && (
|
||||||
|
<UnifiedDataBar
|
||||||
|
context={_udbContext}
|
||||||
|
activeTab={udbTab}
|
||||||
|
onTabChange={setUdbTab}
|
||||||
|
hideTabs={['chats']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className={styles.dossier}>
|
||||||
{/* Context Selector */}
|
{/* Context Selector */}
|
||||||
<div className={styles.contextSelector}>
|
<div className={styles.contextSelector}>
|
||||||
{coach.contexts.map(ctx => (
|
{coach.contexts.map(ctx => (
|
||||||
|
|
@ -286,13 +366,13 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className={styles.tabs}>
|
<div className={styles.tabs}>
|
||||||
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
|
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
>
|
>
|
||||||
{_tabLabel(tab, coach, documents)}
|
{_tabLabel(tab, coach)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -394,6 +474,63 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</AutoScroll>
|
</AutoScroll>
|
||||||
|
|
||||||
|
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
|
||||||
|
<div className={styles.agentActivityPanel}>
|
||||||
|
<button
|
||||||
|
className={styles.agentActivityHeader}
|
||||||
|
onClick={() => setShowAgentActivity(prev => !prev)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={styles.agentActivityTitle}>
|
||||||
|
Agent-Aktivität
|
||||||
|
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className={styles.agentActivityStatus}>
|
||||||
|
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? 'Tool-Aufrufe vorhanden' : 'Warte auf Agent')}
|
||||||
|
</span>
|
||||||
|
<span className={styles.agentActivityChevron}>{showAgentActivity ? '▾' : '▸'}</span>
|
||||||
|
</button>
|
||||||
|
{showAgentActivity && (
|
||||||
|
<div className={styles.agentActivityBody}>
|
||||||
|
{coach.agentToolCalls.length === 0 ? (
|
||||||
|
<div className={styles.agentActivityEmpty}>
|
||||||
|
Noch keine Tool-Aufrufe in dieser Antwort.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
coach.agentToolCalls.map((toolCall, idx) => (
|
||||||
|
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
|
||||||
|
<div className={styles.agentActivityItemHeader}>
|
||||||
|
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.agentActivityBadge} ${
|
||||||
|
toolCall.success === true
|
||||||
|
? styles.agentActivityBadgeSuccess
|
||||||
|
: toolCall.success === false
|
||||||
|
? styles.agentActivityBadgeError
|
||||||
|
: styles.agentActivityBadgeRunning
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toolCall.success === true ? 'fertig' : toolCall.success === false ? 'fehler' : 'läuft'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{toolCall.args && (
|
||||||
|
<div className={styles.agentActivityMeta}>
|
||||||
|
<strong>Args:</strong> {_formatToolPayload(toolCall.args)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolCall.result && (
|
||||||
|
<div className={styles.agentActivityMeta}>
|
||||||
|
<strong>Result:</strong> {toolCall.result}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<div className={styles.inputArea}>
|
<div className={styles.inputArea}>
|
||||||
<div className={styles.voiceStatus}>
|
<div className={styles.voiceStatus}>
|
||||||
|
|
@ -411,6 +548,53 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
: 'Mikrofon wird gestartet...'}
|
: 'Mikrofon wird gestartet...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Attachment Chips */}
|
||||||
|
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
|
||||||
|
{attachedFileIds.map(fId => {
|
||||||
|
const file = wsFiles.find(f => f.id === fId);
|
||||||
|
return (
|
||||||
|
<span key={fId} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||||
|
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{file?.fileName || fId}
|
||||||
|
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>×</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{attachedDsIds.map(dsId => {
|
||||||
|
const ds = wsDataSources.find(d => d.id === dsId);
|
||||||
|
return (
|
||||||
|
<span key={dsId} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||||
|
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{ds?.label || ds?.path || dsId}
|
||||||
|
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>×</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{attachedFdsIds.map(fdsId => {
|
||||||
|
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
|
||||||
|
return (
|
||||||
|
<span key={fdsId} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||||
|
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
|
||||||
|
{fds?.label || fdsId}
|
||||||
|
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>×</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.textInputRow}>
|
<div className={styles.textInputRow}>
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
@ -422,6 +606,153 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={coach.isStreaming}
|
disabled={coach.isStreaming}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* File Picker */}
|
||||||
|
{wsFiles.length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowFilePicker(v => !v); setShowSourcePicker(false); }}
|
||||||
|
disabled={coach.isStreaming}
|
||||||
|
title="Datei anhängen"
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36, borderRadius: 8,
|
||||||
|
border: `1px solid ${attachedFileIds.length ? '#1565c0' : 'var(--border-color, #ddd)'}`,
|
||||||
|
background: attachedFileIds.length ? '#e3f2fd' : 'var(--secondary-bg, #f5f5f5)',
|
||||||
|
color: attachedFileIds.length ? '#1565c0' : '#666',
|
||||||
|
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 15, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
{attachedFileIds.length > 0 && (
|
||||||
|
<span style={{ position: 'absolute', top: -4, right: -4, background: '#1565c0', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{attachedFileIds.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showFilePicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
||||||
|
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||||||
|
minWidth: 220, maxHeight: 240, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>Dateien anhängen</div>
|
||||||
|
{wsFiles.map(f => {
|
||||||
|
const sel = attachedFileIds.includes(f.id);
|
||||||
|
return (
|
||||||
|
<div key={f.id} onClick={() => _toggleFile(f.id)} style={{
|
||||||
|
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: sel ? '#e3f2fd' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||||
|
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
|
||||||
|
>
|
||||||
|
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #1565c0' : '2px solid #ccc', background: sel ? '#1565c0' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
{sel ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.fileName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source Picker */}
|
||||||
|
{(wsDataSources.length > 0 || wsFeatureDataSources.length > 0) && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowSourcePicker(v => !v); setShowFilePicker(false); }}
|
||||||
|
disabled={coach.isStreaming}
|
||||||
|
title="Datenquellen anhängen"
|
||||||
|
style={{
|
||||||
|
width: 36, height: 36, borderRadius: 8,
|
||||||
|
border: `1px solid ${(attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : 'var(--border-color, #ddd)'}`,
|
||||||
|
background: (attachedDsIds.length + attachedFdsIds.length) ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
|
||||||
|
color: (attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : '#666',
|
||||||
|
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔗
|
||||||
|
{(attachedDsIds.length + attachedFdsIds.length) > 0 && (
|
||||||
|
<span style={{ position: 'absolute', top: -4, right: -4, background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{attachedDsIds.length + attachedFdsIds.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showSourcePicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
||||||
|
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||||||
|
minWidth: 240, maxHeight: 260, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{wsDataSources.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>Persönliche Quellen</div>
|
||||||
|
{wsDataSources.map(ds => {
|
||||||
|
const sel = attachedDsIds.includes(ds.id);
|
||||||
|
return (
|
||||||
|
<div key={ds.id} onClick={() => _toggleDs(ds.id)} style={{
|
||||||
|
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: sel ? '#e8f5e9' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||||
|
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
|
||||||
|
>
|
||||||
|
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #2e7d32' : '2px solid #ccc', background: sel ? '#2e7d32' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
{sel ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ds.label || ds.path}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{wsFeatureDataSources.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: wsDataSources.length ? '1px solid #f0f0f0' : 'none', borderBottom: '1px solid #f0f0f0' }}>Feature-Datenquellen</div>
|
||||||
|
{wsFeatureDataSources.map(fds => {
|
||||||
|
const sel = attachedFdsIds.includes(fds.id);
|
||||||
|
return (
|
||||||
|
<div key={fds.id} onClick={() => _toggleFds(fds.id)} style={{
|
||||||
|
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: sel ? '#f3e5f5' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
|
||||||
|
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
|
||||||
|
>
|
||||||
|
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #7b1fa2' : '2px solid #ccc', background: sel ? '#7b1fa2' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
{sel ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{fds.label} – {fds.tableName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Provider Selector */}
|
||||||
|
<ProviderMultiSelect
|
||||||
|
selection={providerSelection}
|
||||||
|
onChange={setProviderSelection}
|
||||||
|
showLabel={false}
|
||||||
|
disabled={coach.isStreaming}
|
||||||
|
/>
|
||||||
|
|
||||||
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
|
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
|
||||||
Senden
|
Senden
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -546,54 +877,10 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{/* DOCUMENTS TAB */}
|
|
||||||
{/* ============================================================ */}
|
|
||||||
{activeTab === 'documents' && (
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<div className={styles.addTaskRow}>
|
|
||||||
<label className={styles.uploadLabel}>
|
|
||||||
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
|
|
||||||
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{documents.length === 0 ? (
|
|
||||||
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.documentList}>
|
|
||||||
{documents.map(doc => (
|
|
||||||
<div key={doc.id} className={styles.documentItem}>
|
|
||||||
<div className={styles.documentInfo}>
|
|
||||||
<div className={styles.documentName}>{doc.fileName}</div>
|
|
||||||
<div className={styles.documentMeta}>
|
|
||||||
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
|
|
||||||
</div>
|
|
||||||
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
|
|
||||||
</div>
|
|
||||||
<div className={styles.documentActions}>
|
|
||||||
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
|
|
||||||
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>)}
|
</>)}
|
||||||
{/* #region agent log */}
|
|
||||||
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
|
|
||||||
<button
|
</div>
|
||||||
onClick={() => { setDebugSnapshot([...debugLogsRef.current]); setDebugVisible(v => !v); }}
|
|
||||||
style={{background:'#333',color:'#0f0',border:'none',padding:'4px 8px',fontSize:'10px',borderRadius:'4px 0 0 0'}}
|
|
||||||
>DBG ({debugLogsRef.current.length})</button>
|
|
||||||
{debugVisible && (
|
|
||||||
<div style={{background:'rgba(0,0,0,0.9)',color:'#0f0',fontSize:'9px',maxHeight:'40vh',overflow:'auto',padding:'4px',fontFamily:'monospace',whiteSpace:'pre-wrap',width:'100vw'}}>
|
|
||||||
{debugSnapshot.map((l,i) => <div key={i}>{l}</div>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* #endregion */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -607,13 +894,12 @@ function _categoryIcon(category: string): string {
|
||||||
return icons[category] || '*';
|
return icons[category] || '*';
|
||||||
}
|
}
|
||||||
|
|
||||||
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string {
|
function _tabLabel(tab: TabKey, coach: any): string {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
|
||||||
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
|
||||||
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
||||||
case 'scores': return `Bewertungen (${coach.scores.length})`;
|
case 'scores': return `Bewertungen (${coach.scores.length})`;
|
||||||
case 'documents': return `Dokumente (${documents.length})`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -634,12 +920,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
||||||
return Object.values(groups);
|
return Object.values(groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _dimensionLabel(dim: string): string {
|
function _dimensionLabel(dim: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||||||
|
|
@ -649,4 +929,14 @@ function _dimensionLabel(dim: string): string {
|
||||||
return labels[dim] || dim;
|
return labels[dim] || dim;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _formatToolPayload(payload: Record<string, unknown>): string {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(payload);
|
||||||
|
if (!serialized) return '';
|
||||||
|
return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized;
|
||||||
|
} catch {
|
||||||
|
return '[unlesbar]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default CommcoachDossierView;
|
export default CommcoachDossierView;
|
||||||
|
|
|
||||||
55
src/pages/views/commcoach/CommcoachKeepAlive.tsx
Normal file
55
src/pages/views/commcoach/CommcoachKeepAlive.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* CommcoachKeepAlive
|
||||||
|
*
|
||||||
|
* Keeps the CommCoach dossier/coaching page mounted across route changes.
|
||||||
|
* Visibility is toggled via CSS so session state, messages, and input state
|
||||||
|
* stay alive when the user leaves and later returns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { CommcoachDossierView } from './CommcoachDossierView';
|
||||||
|
|
||||||
|
const _COMMCOACH_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/(?:coaching|dossier)/;
|
||||||
|
|
||||||
|
interface CommcoachKeepAliveProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisible }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const cachedMandateIdRef = useRef<string>('');
|
||||||
|
const cachedInstanceIdRef = useRef<string>('');
|
||||||
|
|
||||||
|
const match = location.pathname.match(_COMMCOACH_ROUTE_RE);
|
||||||
|
if (match?.[1] && match?.[2]) {
|
||||||
|
cachedMandateIdRef.current = match[1];
|
||||||
|
cachedInstanceIdRef.current = match[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mandateId = cachedMandateIdRef.current;
|
||||||
|
const instanceId = cachedInstanceIdRef.current;
|
||||||
|
if (!mandateId || !instanceId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: isVisible ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 'var(--mobile-topbar-height, 0px)',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommcoachDossierView
|
||||||
|
persistentInstanceId={instanceId}
|
||||||
|
persistentMandateId={mandateId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommcoachKeepAlive;
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
@ -22,6 +24,7 @@ export interface VoiceControllerApi {
|
||||||
ttsPlaying: () => void;
|
ttsPlaying: () => void;
|
||||||
ttsPaused: () => void;
|
ttsPaused: () => void;
|
||||||
ttsEnded: () => void;
|
ttsEnded: () => void;
|
||||||
|
ttsStopped: () => void;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +33,8 @@ export interface VoiceControllerCallbacks {
|
||||||
onInterimText?: (text: string) => void;
|
onInterimText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _DEFAULT_STT_LANGUAGE = 'de-DE';
|
||||||
|
|
||||||
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi {
|
||||||
const [state, setState] = useState<VoiceState>('idle');
|
const [state, setState] = useState<VoiceState>('idle');
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
|
|
@ -38,6 +43,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
const cbRef = useRef(callbacks);
|
const cbRef = useRef(callbacks);
|
||||||
cbRef.current = callbacks;
|
cbRef.current = callbacks;
|
||||||
|
|
||||||
|
const sttLanguageRef = useRef<string>(_DEFAULT_STT_LANGUAGE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.get('/api/voice/preferences').then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const lang = res.data?.sttLanguage || res.data?.ttsLanguage;
|
||||||
|
if (lang) sttLanguageRef.current = lang;
|
||||||
|
}).catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const _dlog = useCallback((tag: string, info?: string) => {
|
const _dlog = useCallback((tag: string, info?: string) => {
|
||||||
const t = new Date();
|
const t = new Date();
|
||||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`;
|
||||||
|
|
@ -68,16 +85,20 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
onError: (err) => _dlog('VOICE-ERR', String(err)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const _startStream = useCallback(() => {
|
||||||
|
return voiceStream.start(sttLanguageRef.current);
|
||||||
|
}, [voiceStream]);
|
||||||
|
|
||||||
const activate = useCallback(async () => {
|
const activate = useCallback(async () => {
|
||||||
if (stateRef.current !== 'idle') return;
|
if (stateRef.current !== 'idle') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
try {
|
try {
|
||||||
await voiceStream.start('de-DE');
|
await _startStream();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_dlog('MIC-ERR', String(err));
|
_dlog('MIC-ERR', String(err));
|
||||||
_setState('idle');
|
_setState('idle');
|
||||||
}
|
}
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const deactivate = useCallback(() => {
|
const deactivate = useCallback(() => {
|
||||||
voiceStream.stop();
|
voiceStream.stop();
|
||||||
|
|
@ -94,15 +115,27 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
const ttsPaused = useCallback(() => {
|
const ttsPaused = useCallback(() => {
|
||||||
if (stateRef.current !== 'botSpeaking') return;
|
if (stateRef.current !== 'botSpeaking') return;
|
||||||
_setState('interrupted');
|
_setState('interrupted');
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
const ttsEnded = useCallback(() => {
|
const ttsEnded = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
||||||
_setState('listening');
|
_setState('listening');
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, voiceStream, _dlog]);
|
}, [_setState, _startStream, _dlog]);
|
||||||
|
|
||||||
|
const ttsStopped = useCallback(() => {
|
||||||
|
const cur = stateRef.current;
|
||||||
|
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
|
||||||
|
voiceStream.stop();
|
||||||
|
if (mutedRef.current) {
|
||||||
|
_setState('interrupted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_setState('listening');
|
||||||
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
|
}, [_setState, _startStream, _dlog, voiceStream]);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
|
|
@ -110,13 +143,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
if (mutedRef.current) {
|
if (mutedRef.current) {
|
||||||
_setMuted(false);
|
_setMuted(false);
|
||||||
if (cur === 'listening' || cur === 'interrupted') {
|
if (cur === 'listening' || cur === 'interrupted') {
|
||||||
voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_setMuted(true);
|
_setMuted(true);
|
||||||
voiceStream.stop();
|
voiceStream.stop();
|
||||||
}
|
}
|
||||||
}, [_setMuted, voiceStream, _dlog]);
|
}, [_setMuted, _startStream, voiceStream, _dlog]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
|
@ -127,6 +160,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
ttsPlaying,
|
ttsPlaying,
|
||||||
ttsPaused,
|
ttsPaused,
|
||||||
ttsEnded,
|
ttsEnded,
|
||||||
|
ttsStopped,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue