diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 8343584..30ce6bb 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -27,6 +27,8 @@ export interface RegisterData { language?: string; enabled?: boolean; privilege?: string; + registrationType?: 'personal' | 'company'; + companyName?: string; } export interface RegisterRequest { @@ -40,6 +42,8 @@ export interface RegisterRequest { authenticationAuthority: string; }; frontendUrl: string; + registrationType?: string; + companyName?: string; } export interface PasswordResetRequestResponse { @@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise; /** Workflow label (enriched by API) */ workflowLabel?: string; - /** Unix timestamp ms (from _createdAt) */ + /** Unix timestamp ms (from sysCreatedAt) */ createdAt?: number; /** Optional due date - configurable in future */ dueAt?: number; diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index 80d44cd..955ada7 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -18,9 +18,9 @@ export interface Automation { nextExecution?: number; executionLogs?: AutomationLog[]; allowedProviders?: string[]; - _createdAt?: number; + sysCreatedAt?: number; _updatedAt?: number; - _createdByUserName?: string; + sysCreatedByUserName?: string; mandateName?: string; featureInstanceName?: string; [key: string]: any; @@ -48,9 +48,9 @@ export interface AutomationTemplate { label: TextMultilingual; overview?: TextMultilingual; template: string; // JSON string with {{KEY:...}} placeholders - _createdAt?: number; - _createdBy?: string; - _createdByUserName?: string; + sysCreatedAt?: number; + sysCreatedBy?: string; + sysCreatedByUserName?: string; } // Workflow action definition from backend @@ -301,7 +301,7 @@ export async function fetchAutomationTemplateById( */ export async function createAutomationTemplateApi( request: ApiRequestFunction, - templateData: Omit + templateData: Omit ): Promise { return await request({ url: '/api/automation-templates', diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 403ba89..76f79fa 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -4,14 +4,12 @@ import { ApiRequestOptions } from '../hooks/useApi'; // TYPES & INTERFACES // ============================================================================ -export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER'; export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; -export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM'; +export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION'; export interface BillingBalance { mandateId: string; mandateName: string; - billingModel: BillingModel; balance: number; currency: string; warningThreshold: number; @@ -41,19 +39,21 @@ export interface BillingTransaction { export interface BillingSettings { id: string; mandateId: string; - billingModel: BillingModel; - defaultUserCredit: number; warningThresholdPercent: number; notifyOnWarning: boolean; notifyEmails: string[]; + autoRechargeEnabled?: boolean; + rechargeAmountCHF?: number; + rechargeMaxPerMonth?: number; } export interface BillingSettingsUpdate { - billingModel?: BillingModel; - defaultUserCredit?: number; warningThresholdPercent?: number; notifyOnWarning?: boolean; notifyEmails?: string[]; + autoRechargeEnabled?: boolean; + rechargeAmountCHF?: number; + rechargeMaxPerMonth?: number; } export interface UsageReport { @@ -69,7 +69,6 @@ export interface AccountSummary { id: string; mandateId: string; userId?: string; - accountType: string; balance: number; warningThreshold: number; enabled: boolean; @@ -305,10 +304,8 @@ export async function fetchUsersForMandateAdmin( export interface MandateBalance { mandateId: string; mandateName: string; - billingModel: BillingModel; totalBalance: number; userCount: number; - defaultUserCredit: number; warningThresholdPercent: number; } diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index ef9b0be..47f5665 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -50,18 +50,6 @@ export interface CoachingPersona { isActive: boolean; } -export interface CoachingDocument { - id: string; - contextId: string; - fileName: string; - mimeType: string; - fileSize: number; - extractedText?: string; - summary?: string; - fileRef?: string; - createdAt?: string; -} - export interface CoachingBadge { id: string; userId: string; @@ -110,8 +98,6 @@ export interface CoachingScore { export interface CoachingUserProfile { id: string; userId: string; - preferredLanguage: string; - preferredVoice?: string; dailyReminderTime?: string; dailyReminderEnabled: boolean; emailSummaryEnabled: boolean; @@ -494,27 +480,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId: return data.profile; } -// ============================================================================ -// Voice API -// ============================================================================ - -export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise { - 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 { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } }); - return data.voices || []; -} - -export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: { - text?: string; language?: string; voiceId?: string; -}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body }); - return data; -} - // ============================================================================ // Persona API (Iteration 2) // ============================================================================ @@ -535,42 +500,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId: await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' }); } -// ============================================================================ -// Document API (Iteration 2) -// ============================================================================ - -export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { - 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 { - 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 = {}; - 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 { - await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' }); -} - // ============================================================================ // Badge API (Iteration 2) // ============================================================================ diff --git a/src/api/promptApi.ts b/src/api/promptApi.ts index 3b5a000..094dcdc 100644 --- a/src/api/promptApi.ts +++ b/src/api/promptApi.ts @@ -9,7 +9,7 @@ export interface Prompt { mandateId: string; content: string; name: string; - _createdBy?: string; + sysCreatedBy?: string; _hideDelete?: boolean; [key: string]: any; // Allow additional properties } diff --git a/src/api/realEstateApi.ts b/src/api/realEstateApi.ts index 08d8ecc..c043485 100644 --- a/src/api/realEstateApi.ts +++ b/src/api/realEstateApi.ts @@ -23,8 +23,8 @@ export interface RealEstateProject { featureInstanceId?: string; perimeter?: any; parzellen?: RealEstateParcel[]; - _createdAt?: number; - _modifiedAt?: number; + sysCreatedAt?: number; + sysModifiedAt?: number; [key: string]: any; } @@ -38,8 +38,8 @@ export interface RealEstateParcel { plz?: string; perimeter?: any; bauzone?: string; - _createdAt?: number; - _modifiedAt?: number; + sysCreatedAt?: number; + sysModifiedAt?: number; [key: string]: any; } diff --git a/src/api/storeApi.ts b/src/api/storeApi.ts index c4a3b7d..78b0768 100644 --- a/src/api/storeApi.ts +++ b/src/api/storeApi.ts @@ -7,14 +7,21 @@ import api from '../api'; +export interface StoreFeatureInstance { + instanceId: string; + mandateId: string; + mandateName: string; + label: string; + isActive: boolean; +} + export interface StoreFeature { featureCode: string; label: Record; icon: string; description: Record; - isActive: boolean; + instances: StoreFeatureInstance[]; canActivate: boolean; - instanceId: string | null; } export interface StoreActivateResponse { @@ -31,17 +38,44 @@ export interface StoreDeactivateResponse { deactivated: boolean; } +export interface UserMandate { + id: string; + name: string; + label: string; +} + +export interface SubscriptionInfo { + plan: string | null; + status: string | null; + maxDataVolumeMB: number | null; + maxFeatureInstances: number | null; + budgetAiCHF: number | null; + currentFeatureInstances: number; + trialEndsAt: string | null; +} + export async function fetchStoreFeatures(): Promise { const response = await api.get('/api/store/features'); return response.data; } -export async function activateStoreFeature(featureCode: string): Promise { - const response = await api.post('/api/store/activate', { featureCode }); +export async function fetchUserMandates(): Promise { + const response = await api.get('/api/store/mandates'); return response.data; } -export async function deactivateStoreFeature(featureCode: string): Promise { - const response = await api.post('/api/store/deactivate', { featureCode }); +export async function fetchSubscriptionInfo(mandateId?: string): Promise { + const params = mandateId ? { mandateId } : {}; + const response = await api.get('/api/store/subscription-info', { params }); + return response.data; +} + +export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise { + const response = await api.post('/api/store/activate', { featureCode, mandateId }); + return response.data; +} + +export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise { + const response = await api.post('/api/store/deactivate', { featureCode, mandateId, instanceId }); return response.data; } diff --git a/src/api/subscriptionApi.ts b/src/api/subscriptionApi.ts index 9fefe9f..b476433 100644 --- a/src/api/subscriptionApi.ts +++ b/src/api/subscriptionApi.ts @@ -19,6 +19,8 @@ export interface SubscriptionPlan { autoRenew: boolean; maxUsers: number | null; maxFeatureInstances: number | null; + maxDataVolumeMB?: number | null; + budgetAiCHF?: number; trialDays: number | null; successorPlanKey: string | null; } diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index 4230612..cc8920b 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -18,10 +18,10 @@ export interface TrusteeOrganisation { label: string; enabled: boolean; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -29,10 +29,10 @@ export interface TrusteeRole { id: string; desc: string; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -43,10 +43,10 @@ export interface TrusteeAccess { userId: string; contractId?: string | null; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -56,10 +56,10 @@ export interface TrusteeContract { label: string; enabled: boolean; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -71,10 +71,10 @@ export interface TrusteeDocument { documentMimeType: string; documentData?: any; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -98,10 +98,10 @@ export interface TrusteePosition { costCenter?: string; bookingReference?: string; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -696,8 +696,8 @@ export interface TrusteePositionDocument { documentId: string; mandateId?: string; featureInstanceId?: string; - _createdAt?: number; - _modifiedAt?: number; + sysCreatedAt?: number; + sysModifiedAt?: number; [key: string]: any; } diff --git a/src/components/Automation2FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/Automation2FlowEditor/editor/Automation2FlowEditor.tsx index dd95cee..8b86d46 100644 --- a/src/components/Automation2FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/Automation2FlowEditor/editor/Automation2FlowEditor.tsx @@ -35,6 +35,7 @@ import { } from '../nodes/runtime/workflowStartSync'; import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry'; import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; +import { usePrompt } from '../../../hooks/usePrompt'; import styles from './Automation2FlowEditor.module.css'; const LOG = '[Automation2]'; @@ -55,6 +56,7 @@ export const Automation2FlowEditor: React.FC = ({ initialWorkflowId, }) => { const { request } = useApiRequest(); + const { prompt: promptInput, PromptDialog } = usePrompt(); const [nodeTypes, setNodeTypes] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); @@ -140,8 +142,20 @@ export const Automation2FlowEditor: React.FC = ({ await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations }); setExecuteResult({ success: true } as ExecuteGraphResponse); } else { - const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow'; - const created = await createWorkflow(request, instanceId, { label, graph, invocations }); + const label = await promptInput('Workflow-Name:', { + title: 'Workflow speichern', + defaultValue: 'Neuer Workflow', + placeholder: 'Name des Workflows', + }); + if (!label) { + setSaving(false); + return; + } + const created = await createWorkflow(request, instanceId, { + label: label.trim() || 'Neuer Workflow', + graph, + invocations, + }); setCurrentWorkflowId(created.id); if (created.invocations?.length) setInvocations(created.invocations); setWorkflows((prev) => [...prev, created]); @@ -152,7 +166,7 @@ export const Automation2FlowEditor: React.FC = ({ } finally { setSaving(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]); const handleLoad = useCallback( async (workflowId: string) => { @@ -436,7 +450,7 @@ export const Automation2FlowEditor: React.FC = ({ )} - + setWorkflowSettingsOpen(false)} diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx index 56524d9..aa81bc0 100644 --- a/src/components/AutomationEditor/AutomationEditor.tsx +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -14,7 +14,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa'; import { Popup } from '../UiComponents/Popup'; import { ActionsPanel } from '../ActionsPanel'; -import { ProviderMultiSelect } from '../ProviderSelector'; +import { ProviderMultiSelect, _defaultProviderSelection, _migrateFromLegacy, _toBackendProviders } from '../ProviderSelector'; +import type { ProviderSelection } from '../ProviderSelector'; +import { useBilling } from '../../hooks/useBilling'; import { useToast } from '../../contexts/ToastContext'; import { useLanguage } from '../../providers/language/LanguageContext'; import { useWorkflowActions } from '../../hooks/useAutomations'; @@ -374,7 +376,8 @@ export const AutomationEditor: React.FC = ({ const [label, setLabel] = useState(''); const [schedule, setSchedule] = useState('0 22 * * *'); const [active, setActive] = useState(false); - const [allowedProviders, setAllowedProviders] = useState([]); + const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); + const { allowedProviders: billingProviders } = useBilling(); // Template multilingual fields const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); @@ -537,7 +540,7 @@ export const AutomationEditor: React.FC = ({ setLabel(def.label || ''); setSchedule(def.schedule || '0 22 * * *'); setActive(def.active ?? false); - setAllowedProviders(def.allowedProviders || []); + setProviderSelection(_migrateFromLegacy(def.allowedProviders || [])); } // Extract template JSON @@ -693,7 +696,7 @@ export const AutomationEditor: React.FC = ({ active, template: templateJson, placeholders, - allowedProviders + allowedProviders: _toBackendProviders(providerSelection, billingProviders), }; } @@ -709,7 +712,7 @@ export const AutomationEditor: React.FC = ({ } finally { setIsSaving(false); } - }, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); + }, [label, schedule, active, providerSelection, billingProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); // Computed values const editorTitle = title || (mode === 'template' @@ -864,12 +867,12 @@ export const AutomationEditor: React.FC = ({ {/* Allowed AI Providers */}

- Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt. + Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.

diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index 52df54d..deab4d3 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -146,7 +146,25 @@ font-size: 10px; color: var(--color-text-secondary, #999); flex-shrink: 0; +} + +.scopeIcons { + display: flex; + gap: 2px; + flex-shrink: 0; + align-items: center; +} + +.rightZone { + display: flex; + align-items: center; + gap: 4px; margin-left: auto; + flex-shrink: 0; +} + +.rightZone .actions { + margin-left: 0; } .rootActions { diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 7e4860a..d4f92ef 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -13,6 +13,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa'; +import { usePrompt } from '../../hooks/usePrompt'; import styles from './FolderTree.module.css'; /* ── Public types ──────────────────────────────────────────────────────── */ @@ -30,6 +31,8 @@ export interface FileNode { mimeType?: string; fileSize?: number; folderId?: string | null; + scope?: string; + neutralize?: boolean; } export interface TreeItem { @@ -62,6 +65,8 @@ export interface FolderTreeProps { onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; onDownloadFolder?: (folderId: string, folderName: string) => Promise; + onScopeChange?: (fileId: string, newScope: string) => void; + onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── Helpers ───────────────────────────────────────────────────────────── */ @@ -146,6 +151,22 @@ function _fileIcon(mime?: string): string { /* ── Selection context threaded through the tree ──────────────────────── */ +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; + +const _SCOPE_LABELS: Record = { + personal: 'Persönlich', + featureInstance: 'Instanz', + mandate: 'Mandant', + global: 'Global', +}; + interface SelectionCtx { selectedItemIds: Set; selectedFileIds: string[]; @@ -156,6 +177,8 @@ interface SelectionCtx { onDeleteFile?: (fileId: string) => Promise; onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; + onScopeChange?: (fileId: string, newScope: string) => void; + onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── File node (leaf) ─────────────────────────────────────────────────── */ @@ -227,39 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { ) : ( {file.fileName} )} - {!renaming && file.fileSize != null && ( - - {(file.fileSize / 1024).toFixed(0)}K - - )} {!renaming && ( - - {sel.onRenameFile && !multiSelected && ( - - )} - {multiSelected && isSelected ? ( - <> - {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( - - )} - {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( - - )} - - ) : ( - (sel.onDeleteFile || sel.onDeleteFiles) && ( - - ) + )} + {multiSelected && isSelected ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : ( + (sel.onDeleteFile || sel.onDeleteFiles) && ( + + ) + )} + + {file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} + {file.scope != null && ( + + + + )} )} @@ -277,6 +331,7 @@ interface TreeNodeProps { showFiles: boolean; filesByFolder: Map; sel: SelectionCtx; + promptFolderName: (message: string) => Promise; onToggle: (id: string) => void; onSelect: (id: string | null) => void; onCreateFolder?: (name: string, parentId: string | null) => Promise; @@ -291,6 +346,7 @@ interface TreeNodeProps { function _TreeNode({ node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, + promptFolderName, onToggle, onSelect, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onDownloadFolder, @@ -321,12 +377,12 @@ function _TreeNode({ const _handleAdd = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onCreateFolder) return; - const name = prompt('Neuer Ordnername:'); + const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) { await onCreateFolder(name.trim(), node.id); if (!expandedIds.has(node.id)) onToggle(node.id); } - }, [onCreateFolder, node.id, expandedIds, onToggle]); + }, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]); const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); @@ -488,6 +544,7 @@ function _TreeNode({ showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} + promptFolderName={promptFolderName} onToggle={onToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} @@ -517,11 +574,13 @@ export default function FolderTree({ expandedIds: externalExpandedIds, onToggleExpand, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, + onScopeChange, onNeutralizeToggle, }: FolderTreeProps) { const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); const [rootDropOver, setRootDropOver] = useState(false); const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); const lastClickedIdRef = useRef(null); + const { prompt: promptFolderName, PromptDialog } = usePrompt(); const expandedIds = externalExpandedIds ?? internalExpandedIds; @@ -634,8 +693,10 @@ export default function FolderTree({ onDeleteFile, onDeleteFiles, onDeleteFolders, + onScopeChange, + onNeutralizeToggle, }; - }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]); + }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]); const _handleRootDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); @@ -699,7 +760,7 @@ export default function FolderTree({ className={styles.actionBtn} onClick={async (e) => { e.stopPropagation(); - const name = prompt('Neuer Ordnername:'); + const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) await onCreateFolder(name.trim(), null); }} title="Neuer Ordner" @@ -720,6 +781,7 @@ export default function FolderTree({ showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} + promptFolderName={promptFolderName} onToggle={_handleToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} @@ -736,6 +798,7 @@ export default function FolderTree({ <_FileItem key={file.id} file={file} sel={sel} /> ))} + ); } diff --git a/src/components/Navigation/MandateNavigation.module.css b/src/components/Navigation/MandateNavigation.module.css index dcb8358..f3cf712 100644 --- a/src/components/Navigation/MandateNavigation.module.css +++ b/src/components/Navigation/MandateNavigation.module.css @@ -282,6 +282,27 @@ margin-top: 0.5rem; } +/* Rename button (inline, hover-visible via TreeNavigation nodeActions) */ +.renameButton { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--text-tertiary, #888); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; +} + +.renameButton:hover { + color: var(--primary-color, #2563eb); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); +} + /* Dark Theme */ :global(.dark-theme) .separator { background: var(--border-dark, #333); diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index e8cc3bc..616a942 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -20,7 +20,7 @@ * - Users, Mandates, Roles, ... */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useNavigation } from '../../hooks/useNavigation'; import type { DynamicBlock, @@ -31,8 +31,11 @@ import type { FeatureView } from '../../hooks/useNavigation'; import { getPageIcon } from '../../config/pageRegistry'; -import { FaSpinner } from 'react-icons/fa'; +import { FaSpinner, FaPen } from 'react-icons/fa'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; +import { usePrompt } from '../../hooks/usePrompt'; +import { useToast } from '../../contexts/ToastContext'; +import api from '../../api'; import styles from './MandateNavigation.module.css'; // ============================================================================= @@ -84,16 +87,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem { * Convert a FeatureInstance to TreeNodeItem (with feature icon) * Instance node gets path to first view so clicking the instance name navigates to dashboard. * Shows the feature icon next to the instance name for visual distinction. + * If user is instance admin, a rename icon appears on hover. */ -function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem { +function featureInstanceToTreeNode( + instance: FeatureInstance, + featureUiComponent: string, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem { const children = instance.views.map(featureViewToTreeNode); + const renameAction = instance.isAdmin && onRename ? ( + + ) : undefined; + return { id: instance.id, label: instance.uiLabel, - icon: getPageIcon(featureUiComponent), // Use feature icon for instance + icon: getPageIcon(featureUiComponent), path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, children, defaultExpanded: false, + actions: renameAction, }; } @@ -106,16 +125,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent * Before: Mandate → Feature → Instance → Views * Now: Mandate → Instance (with feature icon) → Views */ -function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null { +function navigationMandateToTreeNode( + mandate: NavigationMandate, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem | null { if (mandate.features.length === 0) { return null; } - // Flatten: collect all instances from all features directly under mandate const instanceNodes: TreeNodeItem[] = []; for (const feature of mandate.features) { for (const instance of feature.instances) { - instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent)); + instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename)); } } @@ -134,9 +155,12 @@ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | /** * Convert a DynamicBlock to array of TreeNodeItems (mandate nodes) */ -function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] { +function dynamicBlockToTreeNodes( + block: DynamicBlock, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem[] { return block.mandates - .map(navigationMandateToTreeNode) + .map((m) => navigationMandateToTreeNode(m, onRename)) .filter((node): node is TreeNodeItem => node !== null); } @@ -169,18 +193,24 @@ const EmptyState: React.FC = () => ( // ============================================================================= export const MandateNavigation: React.FC = () => { - // Fetch navigation from new API (blocks structure, already filtered by permissions) - const { blocks, loading } = useNavigation('de'); - - // Build navigation items from blocks - // Groups static items into collapsible containers: - // - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.) - // - "Administration": admin items, possibly with subgroups - // - Dynamic block (mandates) renders between them + const { blocks, loading, refresh } = useNavigation('de'); + const { prompt, PromptDialog } = usePrompt(); + const { showWarning } = useToast(); + + const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => { + const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel }); + if (!newLabel || newLabel.trim() === currentLabel) return; + try { + await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() }); + refresh(); + } catch (err: any) { + showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message)); + } + }, [refresh, prompt, showWarning]); + const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; - // Collect static items by category const meineSichtItems: NavigationItem[] = []; let adminItems: NavigationItem[] = []; let adminSubgroups: NavSubgroup[] = []; @@ -199,15 +229,13 @@ export const MandateNavigation: React.FC = () => { } } - // "Meine Sicht" - collapsible container for user-facing pages if (meineSichtItems.length > 0) { items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true)); } - // Dynamic block: mandates with feature instances for (const block of blocks) { if (block.type === 'dynamic') { - const mandateNodes = dynamicBlockToTreeNodes(block); + const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename); if (mandateNodes.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); items.push(...mandateNodes); @@ -215,7 +243,6 @@ export const MandateNavigation: React.FC = () => { } } - // "Administration" - collapsible container for admin pages (with subgroup support) if (adminSubgroups.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({ @@ -236,7 +263,7 @@ export const MandateNavigation: React.FC = () => { } return items; - }, [blocks]); + }, [blocks, _handleRename]); // Check if user has any navigation (static or dynamic) const hasNavigation = blocks.length > 0; @@ -260,6 +287,7 @@ export const MandateNavigation: React.FC = () => { ) : ( )} + ); }; diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css index 5e9f57c..c7c0c7c 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css @@ -257,6 +257,22 @@ color: white; } +/* ============================================ */ +/* NODE ACTIONS (hover-reveal inline icons) */ +/* ============================================ */ + +.nodeActions { + display: none; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + margin-left: auto; +} + +.treeNode:hover .nodeActions { + display: flex; +} + /* ============================================ */ /* DARK THEME */ /* ============================================ */ diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx index babceee..e60cfac 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx @@ -47,6 +47,8 @@ export interface TreeNodeItem { level?: number; /** Data attribute for testing/identification */ dataId?: string; + /** Inline action element rendered at the end of the row (e.g. rename icon) */ + actions?: ReactNode; } export interface TreeSectionItem { @@ -219,6 +221,11 @@ const TreeNode: React.FC = ({ {node.badge} )} + {node.actions && ( + e.stopPropagation()}> + {node.actions} + + )} ); diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 656976d..44697ae 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCurrentUser } from '../../hooks/useUsers'; import { NotificationBell } from '../NotificationBell'; +import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant'; import styles from './UserSection.module.css'; export const UserSection: React.FC = () => { @@ -16,6 +17,7 @@ export const UserSection: React.FC = () => { const [isLoggingOut, setIsLoggingOut] = useState(false); const [showMenu, setShowMenu] = useState(false); const [showLegalModal, setShowLegalModal] = useState(false); + const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden()); const handleLogout = async () => { setIsLoggingOut(true); @@ -41,6 +43,13 @@ export const UserSection: React.FC = () => { setShowLegalModal(true); setShowMenu(false); }; + + const handleOnboarding = () => { + _showOnboarding(); + setOnboardingHidden(false); + navigate('/', { state: { showOnboarding: Date.now() } }); + setShowMenu(false); + }; if (!user) { return null; @@ -61,7 +70,7 @@ export const UserSection: React.FC = () => { + {onboardingHidden && ( + + )} + + + + ); +}; + +export default OnboardingAssistant; diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx new file mode 100644 index 0000000..ae8f4c1 --- /dev/null +++ b/src/components/OnboardingWizard.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react'; +import api from '../api'; + +interface OnboardingWizardProps { + onComplete: () => void; + onDismiss: () => void; +} + +const OnboardingWizard: React.FC = ({ 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(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 ( +
+
+

Mandant erstellen

+

+ Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl. +

+ +
+ + + +
+ +
+ + 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', + }} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +}; + +export default OnboardingWizard; diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css index 04d2be7..384c1c5 100644 --- a/src/components/ProviderSelector/ProviderSelector.module.css +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -53,17 +53,17 @@ justify-content: center; width: 36px; height: 36px; - border: 1px solid var(--border-color, #3a3a3a); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 6px; - background: var(--surface-color, #2d2d2d); - color: var(--text-secondary, #888); + background: var(--surface-color, #ffffff); + color: var(--text-secondary, #666666); cursor: pointer; transition: all 0.2s; } .triggerButton:hover:not(:disabled) { - background: var(--bg-secondary, #3a3a3a); - color: var(--text-primary, #fff); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .triggerButton:disabled { @@ -83,20 +83,20 @@ transform: translateX(-50%); z-index: 1000; padding: 8px; - background: var(--surface-color, #2d2d2d); - border: 1px solid var(--border-color, #3a3a3a); + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 6px; - box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12); min-width: 220px; } .dropdownHeader { font-size: 0.75rem; font-weight: 500; - color: var(--text-secondary, #888); + color: var(--text-secondary, #666666); padding: 4px 8px; margin-bottom: 4px; - border-bottom: 1px solid var(--border-color, #3a3a3a); + border-bottom: 1px solid var(--border-color, #e0e0e0); } .selectActions { @@ -108,18 +108,18 @@ .actionButton { flex: 1; padding: 4px 8px; - border: 1px solid var(--border-color, #3a3a3a); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 4px; - background: var(--bg-secondary, #252525); - color: var(--text-secondary, #888); + background: var(--bg-secondary, #f8f9fa); + color: var(--text-secondary, #666666); font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; } .actionButton:hover:not(:disabled) { - background: var(--bg-hover, #3a3a3a); - color: var(--text-primary, #fff); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .actionButton.active { @@ -138,7 +138,7 @@ flex-direction: column; gap: 2px; padding: 4px; - background: var(--bg-secondary, #252525); + background: var(--bg-secondary, #f8f9fa); border-radius: 4px; max-height: 200px; overflow-y: auto; @@ -151,12 +151,13 @@ padding: 6px 8px; border-radius: 4px; cursor: pointer; - transition: background 0.15s ease; - color: var(--text-primary, #e0e0e0); + transition: background 0.15s ease, color 0.15s ease; + color: var(--text-primary, #1a1a1a); } .checkboxItem:hover { - background: var(--bg-hover, #3a3a3a); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .checkboxItem.disabled { @@ -177,12 +178,12 @@ .providerName { font-size: 0.8rem; - color: var(--text-primary, #e0e0e0); + color: inherit; } .hint { font-size: 0.7rem; - color: var(--text-tertiary, #666); + color: var(--text-tertiary, #888888); text-align: center; padding: 4px 0; } @@ -192,10 +193,24 @@ align-items: center; justify-content: center; padding: 12px; - color: var(--text-secondary, #888); + color: var(--text-secondary, #666666); font-size: 0.8rem; } +/* Dark theme: list hover stays a light lift, not a black wash */ +:global(.dark-theme) .checkboxItem:hover { + background: var(--hover-bg, rgba(255, 255, 255, 0.08)); + color: var(--text-primary, #e5e7eb); +} + +:global(.dark-theme) .checkboxItem { + color: var(--text-primary, #e5e7eb); +} + +:global(.dark-theme) .dropdownContent { + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45); +} + /* ============================================================================ PROVIDER BADGES ============================================================================ */ diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index e41cefb..24cfebc 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -1,19 +1,80 @@ /** * ProviderSelector Component - * + * * Wiederverwendbare Komponente zur Auswahl von AICore-Providern. * Kann im AI Workspace und Automation Editor verwendet werden. - * - * Features: - * - Dropdown für Einzelauswahl - * - Checkbox-Liste für Mehrfachauswahl - * - Lädt verfügbare Provider aus dem Billing-System + * + * Selektionsmodell: + * ProviderSelection { include: string[], exclude: string[] } + * - include(["ALL"]), exclude([]) → alle verfügbaren Provider (dynamisch) + * - include(["ALL"]), exclude(["private"]) → alle ausser "private" (dynamisch) + * - include(["anthropic"]), exclude([]) → nur Anthropic + * - include([]), exclude([]) → keiner ausgewählt + * + * resolveProviders(selection, allowedProviders) liefert die konkrete Liste. */ import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useBilling } from '../../hooks/useBilling'; import styles from './ProviderSelector.module.css'; +// ============================================================================ +// TYPES & HELPERS +// ============================================================================ + +export const PROVIDER_ALL = 'ALL'; + +export interface ProviderSelection { + include: string[]; + exclude: string[]; +} + +export function _defaultProviderSelection(): ProviderSelection { + return { include: [PROVIDER_ALL], exclude: [] }; +} + +export function _resolveProviders( + selection: ProviderSelection, + allowedProviders: string[], +): string[] { + if (selection.include.includes(PROVIDER_ALL)) { + return allowedProviders.filter((p) => !selection.exclude.includes(p)); + } + return selection.include.filter((p) => allowedProviders.includes(p)); +} + +export function _isAllSelected(selection: ProviderSelection): boolean { + return selection.include.includes(PROVIDER_ALL) && selection.exclude.length === 0; +} + +export function _isNoneSelected( + selection: ProviderSelection, + allowedProviders: string[], +): boolean { + return _resolveProviders(selection, allowedProviders).length === 0; +} + +/** + * Migrate legacy string[] (old model) to ProviderSelection. + * [] → ALL, [...ids] → include those ids. + */ +export function _migrateFromLegacy(providers: string[]): ProviderSelection { + if (providers.length === 0) return _defaultProviderSelection(); + return { include: providers, exclude: [] }; +} + +/** + * Convert ProviderSelection to flat list for backend API calls. + * Returns [] when ALL are selected (= no restriction / legacy behaviour). + */ +export function _toBackendProviders( + selection: ProviderSelection, + allowedProviders: string[], +): string[] { + if (_isAllSelected(selection)) return []; + return _resolveProviders(selection, allowedProviders); +} + // Provider display names const PROVIDER_LABELS: Record = { anthropic: 'Anthropic (Claude)', @@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record = { internal: 'Internal', }; -// Provider icons (emojis for simplicity) const PROVIDER_ICONS: Record = { anthropic: '🤖', openai: '💬', @@ -58,20 +118,20 @@ export const ProviderSelect: React.FC = ({ showLabel = true, }) => { const { allowedProviders, loadAllowedProviders, loading } = useBilling(); - + useEffect(() => { if (allowedProviders.length === 0 && !loading) { loadAllowedProviders(); } }, []); - + const providerOptions = useMemo(() => { return allowedProviders.map((provider) => ({ value: provider, label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`, })); }, [allowedProviders]); - + return (
{showLabel && } @@ -93,12 +153,12 @@ export const ProviderSelect: React.FC = ({ }; // ============================================================================ -// MULTI SELECT COMPONENT (Checkbox List) +// MULTI SELECT COMPONENT (Checkbox List) — include / exclude model // ============================================================================ interface ProviderMultiSelectProps { - selectedProviders: string[]; - onChange: (providers: string[]) => void; + selection: ProviderSelection; + onChange: (selection: ProviderSelection) => void; disabled?: boolean; className?: string; label?: string; @@ -108,7 +168,7 @@ interface ProviderMultiSelectProps { } export const ProviderMultiSelect: React.FC = ({ - selectedProviders, + selection, onChange, disabled = false, className, @@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC = ({ const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const containerRef = useRef(null); const { allowedProviders, loadAllowedProviders, loading } = useBilling(); - + useEffect(() => { if (allowedProviders.length === 0 && !loading) { loadAllowedProviders(); } }, []); - - // Apply default exclusions when providers first load + + // Apply default exclusions once when providers first load useEffect(() => { if ( !initialExcludeApplied && allowedProviders.length > 0 && excludeByDefault.length > 0 && - selectedProviders.length === 0 + _isAllSelected(selection) ) { - const initialSelection = allowedProviders.filter( - (p) => !excludeByDefault.includes(p) - ); - // Only apply if there's actually something to exclude - if (initialSelection.length < allowedProviders.length) { - onChange(initialSelection); - } + onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] }); setInitialExcludeApplied(true); } - }, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]); - - // Click outside handler - const handleClickOutside = useCallback((event: MouseEvent) => { + }, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]); + + const _handleClickOutside = useCallback((event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsExpanded(false); } }, []); - + useEffect(() => { if (isExpanded) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('mousedown', _handleClickOutside); + return () => document.removeEventListener('mousedown', _handleClickOutside); } - }, [isExpanded, handleClickOutside]); - - // Effective selection: empty array = all providers active (no restriction) - const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; - - // "Alle" is active when no restriction is set (empty array) OR all explicitly selected - const isAllSelected = selectedProviders.length === 0 || - (allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length); - - const handleToggle = (provider: string) => { - if (selectedProviders.length === 0) { - // Currently "all active" (no restriction) -> make explicit: all except the toggled one - onChange(allowedProviders.filter((p) => p !== provider)); - } else if (selectedProviders.includes(provider)) { - // Deactivate: remove from selection - const remaining = selectedProviders.filter((p) => p !== provider); - // If removing leaves all others selected, reset to [] (= all, no restriction) - if (remaining.length === allowedProviders.length) { - onChange([]); + }, [isExpanded, _handleClickOutside]); + + const effectiveSelection = useMemo( + () => _resolveProviders(selection, allowedProviders), + [selection, allowedProviders], + ); + + const allSelected = _isAllSelected(selection); + const noneSelected = effectiveSelection.length === 0; + + const _handleToggle = (provider: string) => { + const isChecked = effectiveSelection.includes(provider); + + if (selection.include.includes(PROVIDER_ALL)) { + // Currently ALL-based: toggle modifies exclude list + if (isChecked) { + onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] }); } else { - onChange(remaining); + const nextExclude = selection.exclude.filter((p) => p !== provider); + onChange({ include: [PROVIDER_ALL], exclude: nextExclude }); } } else { - // Activate: add to selection - const updated = [...selectedProviders, provider]; - // If all are now selected, reset to [] (= all, no restriction) - if (updated.length === allowedProviders.length) { - onChange([]); + // Explicit include list + if (isChecked) { + onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] }); } else { - onChange(updated); + const nextInclude = [...selection.include, provider]; + if (nextInclude.length === allowedProviders.length) { + onChange({ include: [PROVIDER_ALL], exclude: [] }); + } else { + onChange({ include: nextInclude, exclude: [] }); + } } } }; - - const handleSelectAll = () => { - onChange([]); // Empty = all active, no restriction + + const _handleSelectAll = () => { + onChange({ include: [PROVIDER_ALL], exclude: [] }); }; - - // Summary icon for button + const summaryIcon = useMemo(() => { + if (noneSelected) return '⊘'; if (effectiveSelection.length === 1) { return PROVIDER_ICONS[effectiveSelection[0]] || '🔌'; } - return '🤖'; - }, [effectiveSelection]); - + return '⚡'; + }, [effectiveSelection, noneSelected]); + + const summaryHint = useMemo(() => { + if (noneSelected) return 'Kein Provider ausgewählt'; + if (allSelected) return 'Alle Provider aktiv (dynamisch)'; + if (selection.include.includes(PROVIDER_ALL)) { + return `Alle ausser ${selection.exclude.length} Provider`; + } + return `${effectiveSelection.length} von ${allowedProviders.length} Provider`; + }, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]); + return ( -
- {/* Trigger Button - styled like iconButton */} - - - {/* Dropdown Content */} + {isExpanded && (
{showLabel &&
{label}
} - +
-
- + {loading ? (
Lade...
) : (
{allowedProviders.map((provider) => ( -
)} - - {isAllSelected && !loading && ( -
- Alle Provider aktiv (kein Filter) -
- )} + +
{summaryHint}
)}
@@ -288,7 +346,7 @@ export const ProviderBadges: React.FC = ({ if (providers.length === 0) { return Alle Provider; } - + return (
{providers.map((provider) => ( @@ -300,5 +358,4 @@ export const ProviderBadges: React.FC = ({ ); }; -// Default export export default ProviderSelect; diff --git a/src/components/ProviderSelector/index.ts b/src/components/ProviderSelector/index.ts index afe1b42..a2f6c79 100644 --- a/src/components/ProviderSelector/index.ts +++ b/src/components/ProviderSelector/index.ts @@ -5,6 +5,19 @@ export { ProviderSelect, ProviderMultiSelect, - ProviderBadges + ProviderBadges, } from './ProviderSelector'; + +export { + PROVIDER_ALL, + _defaultProviderSelection, + _resolveProviders, + _isAllSelected, + _isNoneSelected, + _migrateFromLegacy, + _toBackendProviders, +} from './ProviderSelector'; + +export type { ProviderSelection } from './ProviderSelector'; + export { default } from './ProviderSelector'; diff --git a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx index 371f650..77cf5cd 100644 --- a/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx +++ b/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx @@ -10,6 +10,7 @@ import { import { FaDownload, FaLink, FaPlay } from 'react-icons/fa'; import { WorkflowFile } from '../../../hooks/usePlayground'; import styles from './ConnectedFilesList.module.css'; +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; export interface ConnectedFilesListActionButton { type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove'; @@ -240,7 +241,7 @@ export function ConnectedFilesList({
- {formatFileSize(file.fileSize)} + {formatBinaryDataSizeBytes(file.fileSize)} {file.source && ( @@ -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; diff --git a/src/components/UiComponents/Messages/MessageUtils.ts b/src/components/UiComponents/Messages/MessageUtils.ts index 3d40945..ff67d51 100644 --- a/src/components/UiComponents/Messages/MessageUtils.ts +++ b/src/components/UiComponents/Messages/MessageUtils.ts @@ -2,6 +2,8 @@ * Utility functions for message formatting and styling */ +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; + /** * Formats a timestamp to a readable date/time string * Handles both Unix timestamps in seconds and milliseconds @@ -50,16 +52,8 @@ export const formatTimestamp = (timestamp?: number): string => { } }; -/** - * Formats file size to human-readable format - */ -export const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; -}; +/** File / attachment sizes (bytes). Same as formatBinaryDataSizeBytes (B … TB). */ +export const formatFileSize = formatBinaryDataSizeBytes; /** * Gets status badge color class based on status diff --git a/src/components/UiComponents/Messages/MessagesTypes.ts b/src/components/UiComponents/Messages/MessagesTypes.ts index 7aa81b3..04ac70a 100644 --- a/src/components/UiComponents/Messages/MessagesTypes.ts +++ b/src/components/UiComponents/Messages/MessagesTypes.ts @@ -15,6 +15,12 @@ export interface MessageDocument { taskNumber: number; actionNumber: number; actionId: string; + documentName?: string; + validationMetadata?: { + neutralized?: boolean; + skipped?: boolean; + [key: string]: unknown; + }; } /** diff --git a/src/components/UnifiedDataBar/ChatsTab.module.css b/src/components/UnifiedDataBar/ChatsTab.module.css new file mode 100644 index 0000000..44bd65b --- /dev/null +++ b/src/components/UnifiedDataBar/ChatsTab.module.css @@ -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; + } +} diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx new file mode 100644 index 0000000..5c88e8b --- /dev/null +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -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; + onDeleteChat?: (chatId: string) => void | Promise; +} + +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 = ({ + context, + onSelectChat, + onDragStart, + activeWorkflowId, + onCreateNew, + onRenameChat, + onDeleteChat, +}) => { + const [groups, setGroups] = useState([]); + const [flatMode, setFlatMode] = useState(false); + const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('active'); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const renameInputRef = useRef(null); + + const _loadChats = useCallback(async (serverSearch?: string) => { + setLoading(true); + try { + const params: Record = { 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(); + 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 ( +
{ + 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 ? ( + setEditName(e.target.value)} + onBlur={() => _commitRename(chat.id)} + onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <> + + {_formatRelativeTime(chat.updatedAt)} + + + {chat.label} + + + {onRenameChat && ( + + )} + {archived ? ( + + ) : ( + + )} + {onDeleteChat && ( + + )} + + + )} +
+ ); + }; + + const _featureCodeLabel = (code: string): string => { + const labels: Record = { + workspace: 'AI Workspace', + commcoach: 'CommCoach', + trustee: 'Trustee', + automation: 'Automation', + }; + return labels[code] || code; + }; + + if (loading) return
Lade Chats...
; + + return ( +
+
+ setSearch(e.target.value)} + /> + {onCreateNew && ( + + )} + +
+ +
+ + +
+ + {flatMode ? ( +
+ {_allChats.map((chat) => + _renderChatItem(chat, chat.featureInstanceId || context.instanceId), + )} +
+ ) : ( +
+ {(() => { + const byFeatureCode = new Map(); + 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]) => ( +
+
_toggleGroup(`section:${code}`)} + > + + {expandedGroups.has(`section:${code}`) ? '\u25BC' : '\u25B6'} + + + {_featureCodeLabel(code)} + + + {instances.reduce((n, g) => n + g.chats.length, 0)} + +
+ {expandedGroups.has(`section:${code}`) && instances.map((group) => ( +
+ {instances.length > 1 && ( +
_toggleGroup(group.featureInstanceId)} + > + + {expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'} + + {group.featureLabel} + {group.chats.length} +
+ )} + {(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && ( +
+ {group.chats.map((chat) => + _renderChatItem(chat, group.featureInstanceId), + )} +
+ )} +
+ ))} +
+ )); + })()} +
+ )} + + {_allChats.length === 0 && ( +
+ {filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'} +
+ )} +
+ ); +}; + +export default ChatsTab; diff --git a/src/components/UnifiedDataBar/FilesTab.module.css b/src/components/UnifiedDataBar/FilesTab.module.css new file mode 100644 index 0000000..7a48a75 --- /dev/null +++ b/src/components/UnifiedDataBar/FilesTab.module.css @@ -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); + } +} diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx new file mode 100644 index 0000000..e38c991 --- /dev/null +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -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 = ({ context, onFileSelect }) => { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const fileInputRef = useRef(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) => { + 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
Lade Dateien...
; + + return ( +
+ {isDragOver && ( +
+ Dateien hier ablegen +
+ )} + +
+ Files +
+ + +
+
+ + + + 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', + }} + /> + +
+ + + {_fileNodes.length === 0 && ( +
+ {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'} +
+ )} +
+ +
+ {'\uD83D\uDC64'} Persönlich + {'\uD83D\uDC65'} Instanz + {'\uD83C\uDFE2'} Mandant + {'\uD83D\uDD12'} Neutralisiert +
+
+ ); +}; + +export default FilesTab; diff --git a/src/components/UnifiedDataBar/SourcesTab.module.css b/src/components/UnifiedDataBar/SourcesTab.module.css new file mode 100644 index 0000000..793732c --- /dev/null +++ b/src/components/UnifiedDataBar/SourcesTab.module.css @@ -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; +} diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx similarity index 73% rename from src/pages/views/workspace/DataSourcePanel.tsx rename to src/components/UnifiedDataBar/SourcesTab.tsx index 3b3db3d..4db4e7e 100644 --- a/src/pages/views/workspace/DataSourcePanel.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1,20 +1,48 @@ /** - * DataSourcePanel -- Browse external data sources as a lazy-loading tree. + * SourcesTab – Full data-source management inside the Unified Data Bar. * - * Tree structure: + * Tree structure (Browse Sources): * UserConnection (Level 1, loaded on mount) * └─ Service (Level 2, loaded when connection expanded) * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) * - * Each folder node can be added as a DataSource for this workspace instance. + * Feature Data tree: + * MandateGroup + * └─ FeatureConnection (feature instance) + * └─ FeatureTable (tables exposed by that instance) + * + * Active Sources sections show scope-cycling and neutralize-toggle buttons. */ import React, { useEffect, useState, useCallback, useRef } from 'react'; -import api from '../../../api'; -import { getPageIcon } from '../../../config/pageRegistry'; -import type { DataSource, FeatureDataSource } from './useWorkspace'; +import type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import { getPageIcon } from '../../config/pageRegistry'; +import styles from './SourcesTab.module.css'; -/* ─── Types ─────────────────────────────────────────────────────────── */ +/* ─── Types (inline, no external imports) ────────────────────────────── */ + +interface UdbDataSource { + id: string; + connectionId: string; + sourceType: string; + path: string; + label: string; + displayPath?: string; + scope: string; + neutralize: boolean; +} + +interface UdbFeatureDataSource { + id: string; + featureInstanceId: string; + featureCode: string; + tableName: string; + objectKey: string; + label: string; + scope: string; + neutralize: boolean; +} interface TreeNode { key: string; @@ -27,7 +55,6 @@ interface TreeNode { connectionId: string; service?: string; path?: string; - /** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */ displayPath?: string; authority?: string; } @@ -58,15 +85,13 @@ interface FeatureTableNode { fields: string[]; } -interface DataSourcePanelProps { - instanceId: string; - dataSources: DataSource[]; - featureDataSources: FeatureDataSource[]; - onRefresh: () => void; - onRefreshFeatureDataSources: () => void; +/* ─── Props ──────────────────────────────────────────────────────────── */ + +interface SourcesTabProps { + context: UdbContext; } -/* ─── Icons ─────────────────────────────────────────────────────────── */ +/* ─── Icons ──────────────────────────────────────────────────────────── */ const _AUTHORITY_ICONS: Record = { msft: '\uD83D\uDFE6', @@ -113,6 +138,40 @@ function _getSourceIcon(sourceType: string): string { return map[sourceType] || '\uD83D\uDCC1'; } +/* ─── Scope / Neutralize constants ───────────────────────────────────── */ + +const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate']; + +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_LABELS: Record = { + personal: 'Personal', + featureInstance: 'Feature Instance', + mandate: 'Mandate', + global: 'Global', +}; + +function _nextScope(current: string): string { + const idx = _SCOPE_ORDER.indexOf(current); + if (idx === -1) return _SCOPE_ORDER[0]; + return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length]; +} + +/* ─── Tree helpers ───────────────────────────────────────────────────── */ + +function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { + return nodes.map(n => { + if (n.key === key) return updater(n); + if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; + return n; + }); +} + function _mapFeatureTreeUpdate( prev: MandateGroupNode[], featureInstanceId: string, @@ -137,14 +196,14 @@ function _findFeatureInstanceMeta( return null; } -function _personalDataSourceHoverTitle(connLabel: string, ds: DataSource): string { +function _personalDataSourceHoverTitle(connLabel: string, ds: UdbDataSource): string { const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; return pathPart ? `${connLabel} / ${pathPart}` : connLabel; } function _featureDataSourceHoverTitle( meta: { mandateLabel: string; instanceLabel: string } | null, - fds: FeatureDataSource, + fds: UdbFeatureDataSource, ): string { const parts: string[] = []; if (meta) { @@ -160,24 +219,153 @@ function _featureDataSourceHoverTitle( return parts.join(' / '); } -/* ─── Component ─────────────────────────────────────────────────────── */ +/* ─── Data fetching (module-level) ───────────────────────────────────── */ -export const DataSourcePanel: React.FC = ({ - instanceId, - dataSources, - featureDataSources, - onRefresh, - onRefreshFeatureDataSources, -}) => { +async function _loadServices(instanceId: string, connectionId: string): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); + const services = res.data.services || []; + return services.map((s: any) => ({ + key: `svc-${connectionId}-${s.service}`, + label: s.label || s.service, + icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', + type: 'service' as const, + expanded: false, + loading: false, + children: null, + connectionId, + service: s.service, + path: '/', + displayPath: s.label || s.service, + })); +} + +async function _browseService( + instanceId: string, + connectionId: string, + service: string, + path: string, + parentDisplayPath: string | undefined, +): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { + params: { service, path }, + }); + const items = res.data.items || []; + return items.map((entry: any, idx: number) => { + const seg = entry.name || ''; + const displayPath = parentDisplayPath + ? `${parentDisplayPath} / ${seg}` + : seg; + return { + key: `item-${connectionId}-${service}-${entry.path || idx}`, + label: entry.name, + icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), + type: entry.isFolder ? 'folder' as const : 'file' as const, + expanded: false, + loading: false, + children: entry.isFolder ? null : [], + connectionId, + service, + path: entry.path, + displayPath, + }; + }); +} + +function _fileIcon(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() || ''; + const map: Record = { + pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', + xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', + ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', + txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', + png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', + zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', + mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', + mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', + }; + return map[ext] || '\uD83D\uDCC4'; +} + +/* ─── Spinner (inline) ───────────────────────────────────────────────── */ + +function _Spinner(): React.ReactElement { + return ( + + ); +} + +/* ─── Component ──────────────────────────────────────────────────────── */ + +const SourcesTab: React.FC = ({ context }) => { + const instanceId = context.instanceId; + + /* ── Active sources (fetched internally) ── */ + const [dataSources, setDataSources] = useState([]); + const [featureDataSources, setFeatureDataSources] = useState([]); + + /* ── Browse tree state ── */ const [tree, setTree] = useState([]); const [loadingRoot, setLoadingRoot] = useState(false); const [addingPath, setAddingPath] = useState(null); + + /* ── Feature tree state ── */ const [featureTree, setFeatureTree] = useState([]); const [loadingFeatures, setLoadingFeatures] = useState(false); const [addingFeatureKey, setAddingFeatureKey] = useState(null); + const mountedRef = useRef(true); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); + /* ── Fetch active personal data sources ── */ + const _fetchDataSources = useCallback(() => { + if (!instanceId) return; + api.get(`/api/workspace/${instanceId}/datasources`) + .then(res => { + if (!mountedRef.current) return; + const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({ + id: d.id, + connectionId: d.connectionId, + sourceType: d.sourceType, + path: d.path, + label: d.label, + displayPath: d.displayPath, + scope: d.scope || 'personal', + neutralize: d.neutralize ?? false, + })); + setDataSources(list); + }) + .catch(() => { if (mountedRef.current) setDataSources([]); }); + }, [instanceId]); + + /* ── Fetch active feature data sources ── */ + const _fetchFeatureDataSources = useCallback(() => { + if (!instanceId) return; + api.get(`/api/workspace/${instanceId}/feature-datasources`) + .then(res => { + if (!mountedRef.current) return; + const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({ + id: d.id, + featureInstanceId: d.featureInstanceId, + featureCode: d.featureCode, + tableName: d.tableName, + objectKey: d.objectKey, + label: d.label, + scope: d.scope || 'personal', + neutralize: d.neutralize ?? false, + })); + setFeatureDataSources(list); + }) + .catch(() => { if (mountedRef.current) setFeatureDataSources([]); }); + }, [instanceId]); + + useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]); + useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]); + /* ── Load Level 1: UserConnections ── */ const _loadConnections = useCallback(() => { if (!instanceId) return; @@ -271,23 +459,23 @@ export const DataSourcePanel: React.FC = ({ label: node.label, displayPath: node.displayPath || node.label, }); - onRefresh(); + _fetchDataSources(); } catch (err) { console.error('Failed to add data source:', err); } finally { if (mountedRef.current) setAddingPath(null); } - }, [instanceId, onRefresh]); + }, [instanceId, _fetchDataSources]); /* ── Remove DataSource ── */ const _removeDatasource = useCallback(async (dsId: string) => { try { await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); - onRefresh(); + _fetchDataSources(); } catch (err) { console.error('Failed to remove data source:', err); } - }, [instanceId, onRefresh]); + }, [instanceId, _fetchDataSources]); /* ── Check if a path is already added ── */ const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => { @@ -296,6 +484,50 @@ export const DataSourcePanel: React.FC = ({ ); }, [dataSources]); + /* ── Scope change (personal data source, optimistic) ── */ + const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => { + const newScope = _nextScope(ds.scope); + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d)); + try { + await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope }); + } catch { + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d)); + } + }, []); + + /* ── Neutralize toggle (personal data source, optimistic) ── */ + const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => { + const newValue = !ds.neutralize; + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d)); + try { + await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue }); + } catch { + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d)); + } + }, []); + + /* ── Scope change (feature data source, optimistic) ── */ + const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => { + const newScope = _nextScope(fds.scope); + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d)); + try { + await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope }); + } catch { + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d)); + } + }, []); + + /* ── Neutralize toggle (feature data source, optimistic) ── */ + const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => { + const newValue = !fds.neutralize; + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d)); + try { + await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue }); + } catch { + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d)); + } + }, []); + /* ── Feature Connections: Load Level 1 ── */ const _loadFeatureConnections = useCallback(() => { if (!instanceId) return; @@ -384,23 +616,23 @@ export const DataSourcePanel: React.FC = ({ objectKey: table.objectKey, label: table.label?.en || table.label?.de || table.tableName, }); - onRefreshFeatureDataSources(); + _fetchFeatureDataSources(); } catch (err) { console.error('Failed to add feature data source:', err); } finally { if (mountedRef.current) setAddingFeatureKey(null); } - }, [instanceId, onRefreshFeatureDataSources]); + }, [instanceId, _fetchFeatureDataSources]); /* ── Feature: Remove FeatureDataSource ── */ const _removeFeatureDataSource = useCallback(async (fdsId: string) => { try { await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); - onRefreshFeatureDataSources(); + _fetchFeatureDataSources(); } catch (err) { console.error('Failed to remove feature data source:', err); } - }, [instanceId, onRefreshFeatureDataSources]); + }, [instanceId, _fetchFeatureDataSources]); /* ── Feature: check if table already added ── */ const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { @@ -409,9 +641,11 @@ export const DataSourcePanel: React.FC = ({ ); }, [featureDataSources]); + /* ── Render ── */ + return ( -
- {/* Active DataSources */} +
+ {/* ── Active Personal Sources ── */} {dataSources.length > 0 && (
@@ -434,6 +668,27 @@ export const DataSourcePanel: React.FC = ({ {connLabel} – {folder} + +
)} - {/* Tree header */} + {/* ── Browse Sources header ── */}
Browse Sources @@ -462,7 +717,7 @@ export const DataSourcePanel: React.FC = ({
- {/* Tree */} + {/* ── Browse Sources tree ── */} {loadingRoot && tree.length === 0 && (
Loading connections... @@ -487,10 +742,10 @@ export const DataSourcePanel: React.FC = ({ /> ))} - {/* ── Feature Data Section ── */} + {/* ── Divider ── */}
- {/* Active Feature Data Sources */} + {/* ── Active Feature Sources ── */} {featureDataSources.length > 0 && (
@@ -500,33 +755,55 @@ export const DataSourcePanel: React.FC = ({ const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); const fdsConnLabel = meta?.instanceLabel || fds.tableName; return ( -
- - {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {fdsConnLabel} – {fds.tableName} - - -
- ); })} +
+ + {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} + + + {fdsConnLabel} – {fds.tableName} + + + + +
+ ); + })}
)} - {/* Feature Connections Tree */} + {/* ── Feature Data header ── */}
Feature Data @@ -540,6 +817,7 @@ export const DataSourcePanel: React.FC = ({
+ {/* ── Feature Data tree ── */} {loadingFeatures && featureTree.length === 0 && (
Loading feature instances... @@ -567,9 +845,9 @@ export const DataSourcePanel: React.FC = ({ ); }; -/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */ +/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */ -interface TreeNodeViewProps { +interface _TreeNodeViewProps { node: TreeNode; depth: number; onToggle: (node: TreeNode) => void; @@ -578,7 +856,7 @@ interface TreeNodeViewProps { addingPath: string | null; } -const _TreeNodeView: React.FC = ({ +const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ node, depth, onToggle, onAdd, isAdded, addingPath, }) => { const [hovered, setHovered] = useState(false); @@ -646,7 +924,6 @@ const _TreeNodeView: React.FC = ({ )}
- {/* Children */} {node.expanded && node.children && node.children.length > 0 && (
{node.children.map(child => ( @@ -672,9 +949,9 @@ const _TreeNodeView: React.FC = ({ ); }; -/* ─── MandateGroupView (mandate + feature instances) ───────────────── */ +/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ -interface MandateGroupViewProps { +interface _MandateGroupViewProps { group: MandateGroupNode; onToggleGroup: (mandateId: string) => void; onToggleFeature: (node: FeatureConnectionNode) => void; @@ -683,7 +960,7 @@ interface MandateGroupViewProps { addingKey: string | null; } -const _MandateGroupView: React.FC = ({ +const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, }) => { const [hovered, setHovered] = useState(false); @@ -729,9 +1006,9 @@ const _MandateGroupView: React.FC = ({ ); }; -/* ─── FeatureNodeView (feature instance + tables) ─────────────────── */ +/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */ -interface FeatureNodeViewProps { +interface _FeatureNodeViewProps { node: FeatureConnectionNode; onToggle: (node: FeatureConnectionNode) => void; onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; @@ -739,7 +1016,7 @@ interface FeatureNodeViewProps { addingKey: string | null; } -const _FeatureNodeView: React.FC = ({ +const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ node, onToggle, onAddTable, isTableAdded, addingKey, }) => { const [hovered, setHovered] = useState(false); @@ -797,7 +1074,9 @@ const _FeatureNodeView: React.FC = ({ ); }; -interface FeatureTableRowProps { +/* ─── FeatureTableRow ────────────────────────────────────────────────── */ + +interface _FeatureTableRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; @@ -805,7 +1084,7 @@ interface FeatureTableRowProps { isAdding: boolean; } -const _FeatureTableRow: React.FC = ({ +const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ featureNode, table, onAdd, isAdded, isAdding, }) => { const [hovered, setHovered] = useState(false); @@ -852,92 +1131,4 @@ const _FeatureTableRow: React.FC = ({ ); }; -/* ─── Spinner (inline) ──────────────────────────────────────────────── */ - -function _Spinner(): React.ReactElement { - return ( - - ); -} - -/* ─── Data fetching ─────────────────────────────────────────────────── */ - -async function _loadServices(instanceId: string, connectionId: string): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); - const services = res.data.services || []; - return services.map((s: any) => ({ - key: `svc-${connectionId}-${s.service}`, - label: s.label || s.service, - icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', - type: 'service' as const, - expanded: false, - loading: false, - children: null, - connectionId, - service: s.service, - path: '/', - displayPath: s.label || s.service, - })); -} - -async function _browseService( - instanceId: string, - connectionId: string, - service: string, - path: string, - parentDisplayPath: string | undefined, -): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { - params: { service, path }, - }); - const items = res.data.items || []; - return items.map((entry: any, idx: number) => { - const seg = entry.name || ''; - const displayPath = parentDisplayPath - ? `${parentDisplayPath} / ${seg}` - : seg; - return { - key: `item-${connectionId}-${service}-${entry.path || idx}`, - label: entry.name, - icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), - type: entry.isFolder ? 'folder' as const : 'file' as const, - expanded: false, - loading: false, - children: entry.isFolder ? null : [], - connectionId, - service, - path: entry.path, - displayPath, - }; - }); -} - -function _fileIcon(name: string): string { - const ext = name.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', - xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', - ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', - txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', - png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', - zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', - mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', - mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', - }; - return map[ext] || '\uD83D\uDCC4'; -} - -/* ─── Tree map utility ──────────────────────────────────────────────── */ - -function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { - return nodes.map(n => { - if (n.key === key) return updater(n); - if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; - return n; - }); -} +export default SourcesTab; diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.module.css b/src/components/UnifiedDataBar/UnifiedDataBar.module.css new file mode 100644 index 0000000..784d687 --- /dev/null +++ b/src/components/UnifiedDataBar/UnifiedDataBar.module.css @@ -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); + } +} diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx new file mode 100644 index 0000000..8a6ddc9 --- /dev/null +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import ChatsTab from './ChatsTab'; +import FilesTab from './FilesTab'; +import SourcesTab from './SourcesTab'; +import styles from './UnifiedDataBar.module.css'; + +export type UdbTab = 'chats' | 'files' | 'sources'; + +export interface UdbContext { + instanceId: string; + mandateId?: string; + featureInstanceId?: string; + userId?: string; +} + +interface UnifiedDataBarProps { + context: UdbContext; + activeTab?: UdbTab; + onTabChange?: (tab: UdbTab) => void; + hideTabs?: UdbTab[]; + onSelectChat?: (chatId: string, featureInstanceId: string) => void; + activeWorkflowId?: string; + onCreateNewChat?: () => void; + onRenameChat?: (chatId: string, newName: string) => void; + onDeleteChat?: (chatId: string) => void; + onChatDragStart?: (chatId: string, event: React.DragEvent) => void; + onFileSelect?: (fileId: string) => void; + className?: string; +} + +const _TAB_LABELS: Record> = { + 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 = ({ + context, + activeTab: controlledTab, + onTabChange, + hideTabs, + onSelectChat, + activeWorkflowId, + onCreateNewChat, + onRenameChat, + onDeleteChat, + onChatDragStart, + onFileSelect, + className, +}) => { + const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter( + t => !hideTabs?.includes(t), + ); + const [internalTab, setInternalTab] = useState(controlledTab ?? visibleTabs[0] ?? 'chats'); + const currentTab = controlledTab ?? internalTab; + + const _handleTabChange = (tab: UdbTab) => { + setInternalTab(tab); + onTabChange?.(tab); + }; + + return ( +
+
+ {visibleTabs.map((tab) => ( + + ))} +
+
+ {currentTab === 'chats' && !hideTabs?.includes('chats') && ( + + )} + {currentTab === 'files' && !hideTabs?.includes('files') && ( + + )} + {currentTab === 'sources' && !hideTabs?.includes('sources') && ( + + )} +
+
+ ); +}; + +export default UnifiedDataBar; diff --git a/src/components/UnifiedDataBar/index.ts b/src/components/UnifiedDataBar/index.ts new file mode 100644 index 0000000..83b7dfc --- /dev/null +++ b/src/components/UnifiedDataBar/index.ts @@ -0,0 +1,3 @@ +export { default as UnifiedDataBar } from './UnifiedDataBar'; +export type { UdbContext, UdbTab } from './UnifiedDataBar'; +export { useUdlContext } from './useUdlContext'; diff --git a/src/components/UnifiedDataBar/useUdlContext.ts b/src/components/UnifiedDataBar/useUdlContext.ts new file mode 100644 index 0000000..7bb2f89 --- /dev/null +++ b/src/components/UnifiedDataBar/useUdlContext.ts @@ -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]); +} diff --git a/src/hooks/useAdminMandates.ts b/src/hooks/useAdminMandates.ts index 010de50..dcf66a4 100644 --- a/src/hooks/useAdminMandates.ts +++ b/src/hooks/useAdminMandates.ts @@ -165,7 +165,7 @@ export function useMandates() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -305,7 +305,7 @@ export function useMandates() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useAdminRbacRoles.ts b/src/hooks/useAdminRbacRoles.ts index 7260f6b..9134eb7 100644 --- a/src/hooks/useAdminRbacRoles.ts +++ b/src/hooks/useAdminRbacRoles.ts @@ -206,7 +206,7 @@ export function useRbacRoles() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -346,7 +346,7 @@ export function useRbacRoles() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useAdminRbacRules.ts b/src/hooks/useAdminRbacRules.ts index 12ca79b..4daab54 100644 --- a/src/hooks/useAdminRbacRules.ts +++ b/src/hooks/useAdminRbacRules.ts @@ -182,7 +182,7 @@ export function useRbacRules() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -322,7 +322,7 @@ export function useRbacRules() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index 9600549..a67bcac 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -279,6 +279,7 @@ export function useRegister() { interface GoogleAuthResponse { accessToken: string; tokenType: string; + isNewUser?: boolean; user: { username: string; email: string; diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index 28897ac..80cf737 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -536,7 +536,7 @@ export function useAutomationTemplates() { return await fetchAutomationTemplateById(request, templateId); }, [request]); - const createTemplate = useCallback(async (data: Omit) => { + const createTemplate = useCallback(async (data: Omit) => { return await createAutomationTemplateApi(request, data); }, [request]); diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts index f2c4314..785a519 100644 --- a/src/hooks/useBilling.ts +++ b/src/hooks/useBilling.ts @@ -43,7 +43,7 @@ export type { MandateUserSummary, }; -export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi'; +export type { TransactionType, ReferenceType } from '../api/billingApi'; /** * Hook for user billing operations @@ -217,34 +217,21 @@ export function useBillingAdmin(mandateId?: string) { } }, [request, mandateId]); - // Update settings — after billing model change, reload dependent data (accounts / users / tx) const saveSettings = useCallback( async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => { const mId = targetMandateId || mandateId; if (!mId) return null; - const previousModel = settings?.billingModel; - try { const data = await updateSettingsAdmin(request, mId, settingsUpdate); setSettings(data); - const newModel = settingsUpdate.billingModel; - const modelChanged = - newModel !== undefined && newModel !== null && newModel !== previousModel; - if (modelChanged) { - await Promise.all([ - loadAccounts(mId), - loadTransactions(mId, 100), - loadUsers(mId), - ]); - } return data; } catch (err) { console.error('Error saving billing settings:', err); throw err; } }, - [request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers] + [request, mandateId] ); // Add credit (manual, admin) diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx index bb9fbab..ec190d7 100644 --- a/src/hooks/useConfirm.tsx +++ b/src/hooks/useConfirm.tsx @@ -104,7 +104,7 @@ export function useConfirm() { padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500, border: '1px solid var(--color-border, #444)', background: 'transparent', - color: 'var(--text-secondary, #aaa)', + color: 'var(--text-primary, #e8e8e8)', cursor: 'pointer', }} > diff --git a/src/hooks/useInstancePermissions.tsx b/src/hooks/useInstancePermissions.tsx index 3d13ca7..048c27c 100644 --- a/src/hooks/useInstancePermissions.tsx +++ b/src/hooks/useInstancePermissions.tsx @@ -83,11 +83,11 @@ export function useTablePermission(tableName: string) { canDelete: hasAccess(permission.delete), // Record-basierte Prüfungen - canReadRecord: (record: { _createdBy?: string }) => + canReadRecord: (record: { sysCreatedBy?: string }) => canAccessRecord(permission.read, record, userId), - canUpdateRecord: (record: { _createdBy?: string }) => + canUpdateRecord: (record: { sysCreatedBy?: string }) => canAccessRecord(permission.update, record, userId), - canDeleteRecord: (record: { _createdBy?: string }) => + canDeleteRecord: (record: { sysCreatedBy?: string }) => canAccessRecord(permission.delete, record, userId), }; } @@ -296,7 +296,7 @@ export function useInstancePermissions(): InstancePermissions | undefined { */ export function useCanEditRecord( tableName: string, - record: { _createdBy?: string } | undefined, + record: { sysCreatedBy?: string } | undefined, userId: string ): boolean { const { update } = useTablePermission(tableName); @@ -311,7 +311,7 @@ export function useCanEditRecord( */ export function useCanDeleteRecord( tableName: string, - record: { _createdBy?: string } | undefined, + record: { sysCreatedBy?: string } | undefined, userId: string ): boolean { const { delete: deleteLevel } = useTablePermission(tableName); @@ -329,7 +329,7 @@ interface PermissionGateProps { table?: string; view?: string; action?: 'view' | 'read' | 'create' | 'update' | 'delete'; - record?: { _createdBy?: string }; + record?: { sysCreatedBy?: string }; children: React.ReactNode; fallback?: React.ReactNode; } diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index c72d8da..2eaf47b 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -66,6 +66,7 @@ export interface FeatureInstance { uiLabel: string; order: number; views: FeatureView[]; + isAdmin?: boolean; } /** Feature within a mandate */ diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 58a989b..b3706d9 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -10,6 +10,48 @@ import api from '../api'; const _ACCESS_REF_TYPES = new Set(['mandate_access', 'feature_access']); +/** API uses PowerOnModel.sysCreatedAt (seconds); legacy clients used createdAt. */ +function _coerceToUnixSeconds(value: unknown): number | undefined { + if (value == null) return undefined; + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 1e12 ? value / 1000 : value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const asNum = Number(trimmed); + if (!Number.isNaN(asNum)) { + return asNum > 1e12 ? asNum / 1000 : asNum; + } + const parsed = Date.parse(trimmed); + if (!Number.isNaN(parsed)) return parsed / 1000; + } + return undefined; +} + +function _normalizeNotificationFromApi(raw: Record): 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) : {}) + ); +} + // Types export interface NotificationAction { actionId: string; @@ -30,6 +72,7 @@ export interface UserNotification { actions?: NotificationAction[]; actionTaken?: string; actionResult?: string; + /** Creation time as Unix seconds (UTC); filled from API sysCreatedAt when needed */ createdAt: number; readAt?: number; actionedAt?: number; @@ -74,7 +117,7 @@ export function useNotifications() { const url = `/api/notifications${queryString ? `?${queryString}` : ''}`; const response = await api.get(url); - const data = response.data as UserNotification[]; + const data = _normalizeNotificationList(response.data); setNotifications(data); return data; } catch (err: any) { @@ -101,9 +144,9 @@ export function useNotifications() { const listRes = await api.get('/api/notifications', { params: { status: 'unread', limit: 25 }, }); - const list = listRes.data as UserNotification[]; + const list = _normalizeNotificationList(listRes.data); if ( - Array.isArray(list) && + list.length > 0 && list.some(n => n.referenceType && _ACCESS_REF_TYPES.has(n.referenceType)) ) { window.dispatchEvent(new Event('features-changed')); diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx new file mode 100644 index 0000000..c0c8837 --- /dev/null +++ b/src/hooks/usePrompt.tsx @@ -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 once in the component tree. + */ + +import React, { useState, useCallback, useRef, useEffect } from 'react'; + +export interface PromptOptions { + title?: string; + confirmLabel?: string; + cancelLabel?: string; + placeholder?: string; + defaultValue?: string; + variant?: 'primary' | 'danger'; +} + +interface PromptState { + message: string; + options: Required; + resolve: (value: string | null) => void; +} + +const _defaults: Required = { + title: 'Eingabe', + confirmLabel: 'OK', + cancelLabel: 'Abbrechen', + placeholder: '', + defaultValue: '', + variant: 'primary', +}; + +export function usePrompt() { + const [state, setState] = useState(null); + const resolveRef = useRef<((v: string | null) => void) | null>(null); + const inputRef = useRef(null); + + const prompt = useCallback((message: string, options?: PromptOptions): Promise => { + return new Promise((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 ( +
+
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', + }} + > +

+ {options.title} +

+ +

+ {message} +

+ + { + 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', + }} + /> + +
+ + +
+
+
+ ); + }, [state, _handleConfirm, _handleCancel]); + + return { prompt, PromptDialog }; +} diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index bc5c3a5..0454e37 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -157,7 +157,7 @@ export function usePrompts() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -367,7 +367,7 @@ export function usePrompts() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -530,7 +530,7 @@ export function usePromptOperations() { try { // Pass all provided fields (supports partial inline updates like isSystem toggle) - const { id, mandateId, _createdBy, _createdAt, _modifiedAt, _permissions, ...requestBody } = updateData; + const { id, mandateId, sysCreatedBy, sysCreatedAt, sysModifiedAt, _permissions, ...requestBody } = updateData; const updatedPrompt = await updatePromptApi(request, promptId, requestBody as UpdatePromptData); diff --git a/src/hooks/useRealEstate.ts b/src/hooks/useRealEstate.ts index c710d74..35e320c 100644 --- a/src/hooks/useRealEstate.ts +++ b/src/hooks/useRealEstate.ts @@ -165,7 +165,7 @@ function _createRealEstateEntityHook(config: RealEstat .filter(attr => { if (attr.readonly === true || attr.editable === false) return false; if (attr.name === 'id') return false; - const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt']; + const nonEditable = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt']; return !nonEditable.includes(attr.name); }) .map(attr => { @@ -210,7 +210,7 @@ function _createRealEstateEntityHook(config: RealEstat const generateCreateFieldsFromAttributes = useCallback(() => { if (!attributes || attributes.length === 0) return []; return attributes - .filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name)) + .filter(attr => !['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name)) .map(attr => { let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string'; let options: Array<{ value: string | number; label: string }> | undefined; diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index f13dbc3..054eaa6 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -11,40 +11,64 @@ import { fetchStoreFeatures, activateStoreFeature, deactivateStoreFeature, + fetchUserMandates, + fetchSubscriptionInfo, type StoreFeature, + type UserMandate, + type SubscriptionInfo, } from '../api/storeApi'; import { useFeatureStore } from '../stores/featureStore'; interface UseStoreReturn { features: StoreFeature[]; + mandates: UserMandate[]; + subscriptionInfo: SubscriptionInfo | null; loading: boolean; actionLoading: string | null; error: string | null; loadStore: () => Promise; - activate: (featureCode: string) => Promise; - deactivate: (featureCode: string) => Promise; + loadSubscriptionInfo: (mandateId?: string) => Promise; + activate: (featureCode: string, mandateId?: string) => Promise; + deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise; } export function useStore(): UseStoreReturn { const [features, setFeatures] = useState([]); + const [mandates, setMandates] = useState([]); + const [subscriptionInfo, setSubscriptionInfo] = useState(null); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [error, setError] = useState(null); const featureStore = useFeatureStore(); + const loadSubscriptionInfo = useCallback(async (mandateId?: string) => { + try { + const info = await fetchSubscriptionInfo(mandateId); + setSubscriptionInfo(info); + } catch { + // non-critical + } + }, []); + const loadStore = useCallback(async () => { setLoading(true); setError(null); try { - const data = await fetchStoreFeatures(); + const [data, userMandates] = await Promise.all([ + fetchStoreFeatures(), + fetchUserMandates(), + ]); setFeatures(data); + setMandates(userMandates); + const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined; + await loadSubscriptionInfo(firstMandateId); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Failed to load store'; setError(msg); } finally { setLoading(false); } - }, []); + }, [loadSubscriptionInfo]); useEffect(() => { loadStore(); @@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn { await loadStore(); }, [featureStore, loadStore]); - const activate = useCallback(async (featureCode: string) => { + const activate = useCallback(async (featureCode: string, mandateId?: string) => { setActionLoading(featureCode); setError(null); try { - await activateStoreFeature(featureCode); + await activateStoreFeature(featureCode, mandateId); await _refreshAfterAction(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Activation failed'; @@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn { } }, [_refreshAfterAction]); - const deactivate = useCallback(async (featureCode: string) => { + const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => { setActionLoading(featureCode); setError(null); try { - await deactivateStoreFeature(featureCode); + await deactivateStoreFeature(featureCode, mandateId, instanceId); await _refreshAfterAction(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Deactivation failed'; @@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn { } }, [_refreshAfterAction]); - return { features, loading, actionLoading, error, loadStore, activate, deactivate }; + return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate }; } export default useStore; diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts index 244a45e..08ba6d6 100644 --- a/src/hooks/useTrustee.ts +++ b/src/hooks/useTrustee.ts @@ -218,7 +218,7 @@ function _createTrusteeEntityHook(config: TrusteeEntit if (attr.name === 'id') { return false; } - const nonEditableFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt']; + const nonEditableFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -284,7 +284,7 @@ function _createTrusteeEntityHook(config: TrusteeEntit return attributes .filter(attr => { - const systemFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId']; + const systemFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId']; return !systemFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeAccess.ts b/src/hooks/useTrusteeAccess.ts index 3208aef..6e1db73 100644 --- a/src/hooks/useTrusteeAccess.ts +++ b/src/hooks/useTrusteeAccess.ts @@ -175,7 +175,7 @@ export function useTrusteeAccess() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeContracts.ts b/src/hooks/useTrusteeContracts.ts index 8aa9f0c..0b628c1 100644 --- a/src/hooks/useTrusteeContracts.ts +++ b/src/hooks/useTrusteeContracts.ts @@ -175,7 +175,7 @@ export function useTrusteeContracts() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeDocuments.ts b/src/hooks/useTrusteeDocuments.ts index 9dcb79b..2453b0d 100644 --- a/src/hooks/useTrusteeDocuments.ts +++ b/src/hooks/useTrusteeDocuments.ts @@ -176,7 +176,7 @@ export function useTrusteeDocuments() { return false; } // documentData is handled separately (binary upload) - const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'documentData', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeOrganisations.ts b/src/hooks/useTrusteeOrganisations.ts index 8f94f6a..11eb9d1 100644 --- a/src/hooks/useTrusteeOrganisations.ts +++ b/src/hooks/useTrusteeOrganisations.ts @@ -174,7 +174,7 @@ export function useTrusteeOrganisations() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteePositionDocuments.ts b/src/hooks/useTrusteePositionDocuments.ts index 7661d06..e55d053 100644 --- a/src/hooks/useTrusteePositionDocuments.ts +++ b/src/hooks/useTrusteePositionDocuments.ts @@ -163,7 +163,7 @@ export function useTrusteePositionDocuments() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteePositions.ts b/src/hooks/useTrusteePositions.ts index d73a700..c2a51c2 100644 --- a/src/hooks/useTrusteePositions.ts +++ b/src/hooks/useTrusteePositions.ts @@ -178,7 +178,7 @@ export function useTrusteePositions() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeRoles.ts b/src/hooks/useTrusteeRoles.ts index 4e9e617..5be6605 100644 --- a/src/hooks/useTrusteeRoles.ts +++ b/src/hooks/useTrusteeRoles.ts @@ -176,7 +176,7 @@ export function useTrusteeRoles() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index 49f1a44..130c285 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -412,7 +412,7 @@ export function useOrgUsers() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -560,7 +560,7 @@ export function useOrgUsers() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete', 'authenticationAuthority']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete', 'authenticationAuthority']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index 512ee41..9a45caf 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -224,7 +224,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?: return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7ff80d0..d3ad680 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -7,11 +7,12 @@ */ import React from 'react'; -import { Link, Navigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import useNavigation from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import { getPageIcon } from '../config/pageRegistry'; import { FaArrowRight, FaBuilding } from 'react-icons/fa'; +import OnboardingAssistant from '../components/OnboardingAssistant'; import styles from './Dashboard.module.css'; // ============================================================================= @@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => { ); } - if (totalInstances === 0) { - return ; - } - return (

Übersicht

-

- Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}. -

+ {totalInstances > 0 && ( +

+ Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}. +

+ )}
+ +
{mandates .filter(mandate => mandate.features.some(f => f.instances.length > 0)) diff --git a/src/pages/Login.module.css b/src/pages/Login.module.css index a66ca81..16905c5 100644 --- a/src/pages/Login.module.css +++ b/src/pages/Login.module.css @@ -242,6 +242,50 @@ text-decoration: underline; } +.ctaSection { + display: flex; + gap: 0.75rem; + width: 100%; +} + +.ctaPrimary { + flex: 1; + height: 46px; + padding: 10px 16px; + border-radius: 25px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + border: none; + background-color: var(--color-secondary); + color: var(--color-text); + transition: all 0.2s ease; + font-family: var(--font-family); +} + +.ctaPrimary:hover { + background-color: var(--color-secondary-hover); +} + +.ctaSecondary { + flex: 1; + height: 46px; + padding: 10px 16px; + border-radius: 25px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--color-secondary); + background-color: transparent; + color: var(--color-secondary); + transition: all 0.2s ease; + font-family: var(--font-family); +} + +.ctaSecondary:hover { + background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent); +} + button:disabled { opacity: 0.7; cursor: not-allowed; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index f5673ee..4ac295f 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa'; import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { PENDING_INVITATION_KEY } from './InvitePage'; +import OnboardingWizard from '../components/OnboardingWizard'; import styles from './Login.module.css'; @@ -21,6 +22,7 @@ function Login() { const { login, error: loginError, isLoading: isLoginLoading } = useAuth(); const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); + const [showOnboardingWizard, setShowOnboardingWizard] = useState(false); // Check for pending invitation const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); @@ -84,6 +86,10 @@ function Login() { console.log("Attempting Google login..."); const response = await loginWithGoogle(); console.log("Google login successful:", response); + if (response?.isNewUser) { + setShowOnboardingWizard(true); + return; + } handleSuccessfulLogin(); } catch (error) { console.error("Google login failed:", error); @@ -104,6 +110,21 @@ function Login() { } }; + if (showOnboardingWizard) { + return ( + { + setShowOnboardingWizard(false); + handleSuccessfulLogin(); + }} + onDismiss={() => { + setShowOnboardingWizard(false); + handleSuccessfulLogin(); + }} + /> + ); + } + return (
@@ -213,12 +234,15 @@ function Login() {
- Du hast noch keinen Konto? + Du hast noch kein Konto? +
+
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 8c71330..8060b2c 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -19,7 +19,6 @@ function Register() { const { register, error: registerError, isLoading } = useRegister(); const { error: msalError } = useMsalRegister(); const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); - // Pre-fill from invitation if provided via location.state const invitationUsername = (location.state as any)?.invitationUsername || ''; const invitationEmail = (location.state as any)?.invitationEmail || ''; const [formData, setFormData] = useState({ @@ -34,15 +33,11 @@ function Register() { const [fullNameFocused, setFullNameFocused] = useState(false); const [usernameHighlight, setUsernameHighlight] = useState(false); - // Check for pending invitation const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const hasPendingInvitation = !!pendingInvitationToken; - // Set page title and generate CSRF token useEffect(() => { document.title = "PowerOn AI Platform - Registrieren"; - - // Generate CSRF token for new security implementation generateAndStoreCSRFToken(); }, []); @@ -53,13 +48,12 @@ function Register() { [name]: value })); setValidationError(null); - // Reset username highlight when user starts typing in username field if (name === 'username') { setUsernameHighlight(false); } }; - const validateForm = (): boolean => { + const _validateForm = (): boolean => { if (!formData.username || !formData.email || !formData.fullName) { setValidationError('Bitte füllen Sie alle Pflichtfelder aus.'); return false; @@ -76,16 +70,14 @@ function Register() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!validateForm()) { + if (!_validateForm()) { return; } try { - // First check username availability const availabilityResult = await checkAvailability(formData.username, 'local'); if (!availabilityResult.available) { - // Check if the error message is about username being taken const errorMessage = availabilityResult.message || 'Username is not available'; if (errorMessage === 'Username is already taken') { setValidationError('Benutzername ist bereits vergeben'); @@ -96,25 +88,20 @@ function Register() { return; } - // Username is available, proceed with registration (no password - magic link flow) - await register(formData); + await register({ ...formData, registrationType: 'personal' }); - // Build success message let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'; if (hasPendingInvitation) { message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.'; } - // Show success message instead of immediate redirect setSuccessMessage(message); - // Redirect to login page after delay setTimeout(() => { navigate('/login', { state: { registered: true, message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.', - // Pass along invitation state ...(location.state || {}) } }); @@ -124,8 +111,7 @@ function Register() { } }; - // Helper function to safely get error message - const getErrorMessage = () => { + const _getErrorMessage = () => { if (validationError) return validationError; if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed'; if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed'; @@ -146,7 +132,6 @@ function Register() {
- {/* Pending invitation notice */} {hasPendingInvitation && !successMessage && (
@@ -154,8 +139,8 @@ function Register() {
)} - {getErrorMessage() && ( -
{getErrorMessage()}
+ {_getErrorMessage() && ( +
{_getErrorMessage()}
)} {successMessage && ( @@ -221,7 +206,7 @@ function Register() { onClick={handleSubmit} disabled={isLoading || isChecking} > - {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : "Registrieren"} + {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'} )} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 12bdec7..bfe6de9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,18 +1,32 @@ /** - * Settings Page - * - * Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext). + * Settings Page — User-level settings with tabs. + * Route: /settings */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { useLanguage } from '../providers/language/LanguageContext'; import { useCurrentUser, useUser } from '../hooks/useUsers'; import { setUserDataCache, getUserDataCache } from '../utils/userCache'; import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; +import { useApiRequest } from '../hooks/useApi'; import styles from './Settings.module.css'; +// ============================================================================= +// TYPES +// ============================================================================= + +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy'; + +const _TABS: { key: SettingsTab; label: string }[] = [ + { key: 'profile', label: 'Profil' }, + { key: 'appearance', label: 'Darstellung' }, + { key: 'voice', label: 'Stimme & Sprache' }, + { key: 'neutralization', label: 'Neutralisierung (lokal)' }, + { key: 'privacy', label: 'Datenschutz' }, +]; + // ============================================================================= // PROFILE EDIT MODAL // ============================================================================= @@ -27,39 +41,13 @@ interface ProfileEditModalProps { const ProfileEditModal: React.FC = ({ isOpen, onClose, userData, onSave }) => { const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); - - // Define editable profile fields + const profileAttributes: AttributeDefinition[] = [ - { - name: 'fullName', - type: 'string', - label: 'Vollständiger Name', - description: 'Ihr vollständiger Name', - required: false, - placeholder: 'Max Mustermann' - }, - { - name: 'email', - type: 'email', - label: 'E-Mail-Adresse', - description: 'Ihre E-Mail-Adresse für Benachrichtigungen', - required: true, - placeholder: 'name@example.com' - }, - { - name: 'language', - type: 'select', - label: 'Sprache', - description: 'Anzeigesprache der Anwendung', - required: true, - options: [ - { value: 'de', label: 'Deutsch' }, - { value: 'en', label: 'English' }, - { value: 'fr', label: 'Français' } - ] - } + { name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' }, + { name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' }, + { name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] }, ]; - + const handleSubmit = async (formData: any) => { setIsSaving(true); setError(null); @@ -72,9 +60,9 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us setIsSaving(false); } }; - + if (!isOpen) return null; - + return (
e.stopPropagation()}> @@ -84,21 +72,358 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us
{error &&
{error}
} - +
); }; +// ============================================================================= +// 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(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [sttLanguage, setSttLanguage] = useState('de-DE'); + const [languages, setLanguages] = useState([]); + const [voiceMap, setVoiceMap] = useState([]); + + const [addLanguage, setAddLanguage] = useState('de-DE'); + const [addVoices, setAddVoices] = useState([]); + 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 = 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 = {}; + 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
Einstellungen werden geladen...
; + + return ( + <> + {error &&
{error}
} + {success &&
{success}
} + +
+

STT-Sprache (Spracheingabe)

+
+
+ +

Wird fuer die Sprache-zu-Text-Erkennung verwendet.

+
+
+ +
+
+
+ +
+

TTS-Stimmen (Sprachausgabe)

+

+ Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. +

+ + {voiceMap.length === 0 ? ( +
+ Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. +
+ ) : ( + + + + {voiceMap.map(entry => ( + + + + + + + ))} + +
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} + + + +
+ )} + +
+
+ + +
+
+ + +
+ + +
+
+ + + + ); +}; + +// ============================================================================= +// 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Mappings werden geladen...
; + + return ( + <> + {error &&
{error}
} + +
+

Platzhalter-Mappings (lokal)

+
+ AI-Workspace: Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '} + Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“ (nicht auf dieser + Seite). Dieser Tab zeigt nur die lokale Liste über /api/local/neutralization-mappings. +
+

+ 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. +

+ + {mappings.length === 0 ? ( +
+ Keine Neutralisierungs-Mappings vorhanden. +
+ ) : ( + + + + + + + + + + {mappings.map(m => ( + + + + + + + ))} + +
Platzhalter-IDOriginaltextTyp +
{m.id.slice(0, 12)}...{_maskText(m.originalText)} + + {m.patternType} + + + +
+ )} +
+ + ); +}; + // ============================================================================= // SETTINGS PAGE // ============================================================================= @@ -107,266 +432,142 @@ export const SettingsPage: React.FC = () => { const { currentLanguage, setLanguage } = useLanguage(); const { user: currentUser, refetch: refetchUser } = useCurrentUser(); const { updateUser } = useUser(); - - const [theme, setTheme] = useState<'light' | 'dark'>( - () => (localStorage.getItem('theme') as 'light' | 'dark') || 'light' - ); + + const [activeTab, setActiveTab] = useState('profile'); + const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); const [isSavingLanguage, setIsSavingLanguage] = useState(false); const [languageError, setLanguageError] = useState(null); - - // Handle theme change + const handleThemeChange = (newTheme: 'light' | 'dark') => { setTheme(newTheme); localStorage.setItem('theme', newTheme); - - if (newTheme === 'dark') { - document.documentElement.classList.add('dark-theme'); - document.documentElement.classList.remove('light-theme'); - } else { - document.documentElement.classList.add('light-theme'); - document.documentElement.classList.remove('dark-theme'); - } + if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); } + else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); } document.documentElement.setAttribute('data-theme', newTheme); }; - - // Handle language change - save to backend and update cache + const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => { if (!currentUser?.id || !currentUser?.username) return; - setIsSavingLanguage(true); setLanguageError(null); - try { - // 1. Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: currentUser.email, - fullName: currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // 2. Save to backend - await updateUser(currentUser.id, userUpdateData); - - // 3. Update sessionStorage cache + await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: currentUser.email, fullName: currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ ...cachedUser, language: newLanguage }); - } - - // 4. Update UI language context + if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage }); setLanguage(newLanguage); - - // 5. Dispatch event to notify other components window.dispatchEvent(new CustomEvent('userInfoUpdated')); - - console.log('Language updated successfully to:', newLanguage); - } catch (err: any) { - console.error('Failed to update language:', err); - setLanguageError('Sprache konnte nicht gespeichert werden'); - } finally { - setIsSavingLanguage(false); - } + } catch { setLanguageError('Sprache konnte nicht gespeichert werden'); } + finally { setIsSavingLanguage(false); } }, [currentUser, updateUser, setLanguage]); - - // Handle profile save + const handleProfileSave = useCallback(async (formData: any) => { if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet'); - - // Get the new language (from form or current user) const newLanguage = formData.language || currentUser.language || 'de'; - - // Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: formData.email || currentUser.email, - fullName: formData.fullName || currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // Update user via API - const updatedUser = await updateUser(currentUser.id, userUpdateData); - - // Update sessionStorage cache + const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ - ...cachedUser, - fullName: updatedUser.fullName || cachedUser.fullName, - email: updatedUser.email || cachedUser.email, - language: newLanguage - }); - } - - // Update UI language if changed - if (newLanguage !== currentLanguage) { - setLanguage(newLanguage as 'de' | 'en' | 'fr'); - } - - // Refetch user data - if (refetchUser) { - await refetchUser(); - } - - // Dispatch event to notify other components (e.g., sidebar) + if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage }); + if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr'); + if (refetchUser) await refetchUser(); window.dispatchEvent(new CustomEvent('userInfoUpdated')); - }, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]); - + return (

Einstellungen

-

Persönliche Einstellungen und Präferenzen

+

Persoenliche Einstellungen und Praeferenzen

- + + +
- {/* Darstellung */} -
-

Darstellung

- -
-
- -

- Wähle zwischen hellem und dunklem Design. -

-
-
-
- - + {activeTab === 'profile' && ( + <> +
+

Konto

+
+
+ +

Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.

+
+
+ +
+
+ {currentUser && ( +
+
Benutzername{currentUser.username}
+
Name{currentUser.fullName || '-'}
+
E-Mail{currentUser.email || '-'}
+
+ )} +
+
+

Ueber

+
+
Version2.0.0
+
Build2026.03.23
+
+
+ + )} + + {activeTab === 'appearance' && ( +
+

Darstellung

+
+

Waehlen Sie zwischen hellem und dunklem Design.

+
+
+ + +
-
- -
-
- -

- Wähle die Anzeigesprache der Anwendung. - {languageError && {languageError}} -

-
-
- - {isSavingLanguage && Speichern...} -
-
-
- - {/* Konto */} -
-

Konto

- -
-
- -

- Ändere deinen Namen und E-Mail-Adresse. -

-
-
- -
-
- - {/* Current user info display */} - {currentUser && ( -
-
- Benutzername - {currentUser.username} -
-
- Name - {currentUser.fullName || '-'} -
-
- E-Mail - {currentUser.email || '-'} +
+

Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && {languageError}}

+
+ + {isSavingLanguage && Speichern...}
- )} -
- - {/* Datenschutz */} -
-

Datenschutz

- -
-
- -

- Data export, portability and account deletion. -

+
+ )} + + {activeTab === 'voice' && } + + {activeTab === 'neutralization' && } + + {activeTab === 'privacy' && ( +
+

Datenschutz

+

+ Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich + nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export, + Löschung) finden Sie unter GDPR. +

+
+

Datenexport, Portabilität und Kontolöschung.

+
GDPR öffnen
-
- - Open GDPR page - -
-
- - - {/* Info */} -
-

Über

- -
-
- Version - 2.0.0 -
-
- Build - 2026.01.20 -
-
-
+ + )}
- - {/* Profile Edit Modal */} - setIsProfileModalOpen(false)} - userData={currentUser} - onSave={handleProfileSave} - /> + + setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} />
); }; diff --git a/src/pages/Store.module.css b/src/pages/Store.module.css index 6383188..d12f7e4 100644 --- a/src/pages/Store.module.css +++ b/src/pages/Store.module.css @@ -29,6 +29,52 @@ font-size: 0.9375rem; } +/* Subscription Banner */ +.subscriptionBanner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + font-size: 0.8125rem; + background: var(--info-bg, #eff6ff); + border: 1px solid var(--info-border, #bfdbfe); + color: var(--info-color, #1e40af); +} + +.bannerSeparator::before { + content: '|'; + margin-right: 0.25rem; + opacity: 0.4; +} + +/* Mandate Select */ +.mandateSelect { + width: 100%; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + font-size: 0.8125rem; + background: var(--surface-color, #ffffff); + color: var(--text-primary, #1a1a1a); + appearance: auto; +} + +.mandateSelect:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.mandateHint { + margin: 0 0 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary, #666); + font-style: italic; +} + /* Grid */ .grid { display: grid; @@ -120,8 +166,54 @@ background: currentColor; } +/* Instance List */ +.instanceList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.instanceRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.instanceInfo { + min-width: 0; + overflow: hidden; +} + +.deactivateButtonSmall { + flex-shrink: 0; + padding: 0.25rem 0.625rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; + color: var(--text-secondary, #666); +} + +.deactivateButtonSmall:hover:not(:disabled) { + border-color: var(--error-color, #dc2626); + color: var(--error-color, #dc2626); + background: var(--error-bg, #fef2f2); +} + +.deactivateButtonSmall:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* Actions */ .cardActions { + display: flex; + flex-direction: column; + gap: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border-color, #e0e0e0); } @@ -243,17 +335,35 @@ border-top-color: var(--border-dark, #333); } -:global(.dark-theme) .deactivateButton { +:global(.dark-theme) .deactivateButton, +:global(.dark-theme) .deactivateButtonSmall { border-color: var(--border-dark, #444); color: var(--text-secondary-dark, #aaa); } -:global(.dark-theme) .deactivateButton:hover:not(:disabled) { +:global(.dark-theme) .deactivateButton:hover:not(:disabled), +:global(.dark-theme) .deactivateButtonSmall:hover:not(:disabled) { border-color: var(--error-color-dark, #f87171); color: var(--error-color-dark, #f87171); background: rgba(248, 113, 113, 0.1); } +:global(.dark-theme) .subscriptionBanner { + background: rgba(37, 99, 235, 0.1); + border-color: rgba(37, 99, 235, 0.25); + color: var(--primary-light, #93bbfc); +} + +:global(.dark-theme) .mandateSelect { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #444); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .mandateHint { + color: var(--text-secondary-dark, #aaa); +} + :global(.dark-theme) .error { background: var(--error-bg-dark, #450a0a); border-color: var(--error-border-dark, #991b1b); diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index fd0e467..ee605c1 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -1,16 +1,15 @@ /** - * Store Page - * - * Feature Store where users can self-activate features in the root mandate. - * Uses the Shared Instance Pattern -- each feature has one shared instance, - * and users get their own FeatureAccess + user-role upon activation. + * Feature Store -- Users activate feature instances in their own mandates. + * Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance + * in the selected mandate. Explicit mandate selection required. */ import React from 'react'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { useStore } from '../hooks/useStore'; -import type { StoreFeature } from '../api/storeApi'; +import type { StoreFeature, UserMandate } from '../api/storeApi'; +import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize'; import styles from './Store.module.css'; const FEATURE_ICONS: Record = { @@ -62,23 +61,27 @@ function _getDescription(featureCode: string, lang: string): string { interface FeatureCardProps { feature: StoreFeature; language: string; + mandates: UserMandate[]; actionLoading: string | null; - onActivate: (code: string) => void; - onDeactivate: (code: string) => void; + onActivate: (code: string, mandateId?: string) => void; + onDeactivate: (code: string, mandateId: string, instanceId: string) => void; } const FeatureCard: React.FC = ({ feature, language, + mandates, actionLoading, onActivate, onDeactivate, }) => { const isProcessing = actionLoading === feature.featureCode; const icon = FEATURE_ICONS[feature.featureCode]; + const activeInstances = feature.instances.filter(inst => inst.isActive); + const hasActive = activeInstances.length > 0; return ( -
+
{icon && {icon}}

@@ -92,37 +95,56 @@ const FeatureCard: React.FC = ({

-
- - - {feature.isActive - ? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active') - : (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')} - -
+ {activeInstances.length > 0 && ( +
+ {activeInstances.map((inst) => ( +
+
+ + + {inst.mandateName || inst.label} + +
+ +
+ ))} +
+ )} + + {activeInstances.length === 0 && ( +
+ + + {language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'} + +
+ )}
- {feature.isActive ? ( + {feature.canActivate && mandates.map((m) => ( - ) : ( - - )} + ))}
); @@ -130,7 +152,7 @@ const FeatureCard: React.FC = ({ const StorePage: React.FC = () => { const { currentLanguage } = useLanguage(); - const { features, loading, actionLoading, error, activate, deactivate } = useStore(); + const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore(); return (
@@ -145,6 +167,33 @@ const StorePage: React.FC = () => {

+ {subscriptionInfo && subscriptionInfo.plan && ( +
+ Plan: {subscriptionInfo.plan} + {subscriptionInfo.maxFeatureInstances != null && ( + + {currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances} + + )} + {subscriptionInfo.maxDataVolumeMB != null && ( + + {currentLanguage === 'de' ? 'Speicher' : 'Storage'}:{' '} + {formatBinaryDataSizeFromMebibytes(subscriptionInfo.maxDataVolumeMB)} + + )} + {subscriptionInfo.budgetAiCHF != null && subscriptionInfo.budgetAiCHF > 0 && ( + + {currentLanguage === 'de' ? 'AI-Budget' : 'AI budget'}: {subscriptionInfo.budgetAiCHF} CHF + + )} + {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( + + {currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()} + + )} +
+ )} + {error &&
{error}
} {loading ? ( @@ -164,6 +213,7 @@ const StorePage: React.FC = () => { key={feature.featureCode} feature={feature} language={currentLanguage} + mandates={mandates} actionLoading={actionLoading} onActivate={activate} onDeactivate={deactivate} diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx index f17c49f..cac150a 100644 --- a/src/pages/admin/AdminMandatesPage.tsx +++ b/src/pages/admin/AdminMandatesPage.tsx @@ -14,6 +14,7 @@ import { splitMandateAndBillingFromForm, } from '../../utils/mandateBillingFormMerge'; import { useToast } from '../../contexts/ToastContext'; +import { usePrompt } from '../../hooks/usePrompt'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa'; @@ -23,6 +24,7 @@ export const AdminMandatesPage: React.FC = () => { const navigate = useNavigate(); const { request } = useApiRequest(); const { showWarning, showSuccess } = useToast(); + const { prompt, PromptDialog } = usePrompt(); const { mandates, columns, @@ -111,11 +113,18 @@ export const AdminMandatesPage: React.FC = () => { setEditingBillingWarning(null); }; - // Handle delete (confirmation handled by DeleteActionButton) - // System mandates (isSystem=true) are protected from deletion const handleDeleteMandate = async (mandate: Mandate) => { if (mandate.isSystem) { - return; // Safety guard - should not be reachable due to disabled button + return; + } + const entered = await prompt( + `Um den Mandanten "${mandate.name}" unwiderruflich zu löschen, geben Sie den Namen ein:`, + { title: 'Mandant löschen', confirmLabel: 'Löschen', variant: 'danger', placeholder: mandate.name }, + ); + if (entered === null) return; + if (entered !== mandate.name) { + showWarning('Löschung abgebrochen', 'Der eingegebene Name stimmt nicht überein.'); + return; } await handleDelete(mandate.id); }; @@ -267,6 +276,8 @@ export const AdminMandatesPage: React.FC = () => {
)} + + {/* Edit Modal */} {editingFormData && (
{ if (billingSaved) { showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert'); } + window.dispatchEvent(new CustomEvent('features-changed')); await loadMandates(); } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } }; message?: string }; diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index ce14359..54861ab 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -220,7 +220,7 @@ export const ConnectionsPage: React.FC = () => { // Form attributes for edit modal const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked']; + const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked']; return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); @@ -244,7 +244,9 @@ export const ConnectionsPage: React.FC = () => {

Verbindungen

-

OAuth-Verbindungen verwalten (Google, Microsoft, ClickUp)

+

+ Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp) +

)} +
); }; diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index f4835e9..edeca0c 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -53,7 +53,7 @@ export const PromptsPage: React.FC = () => { // Generate columns from attributes - exclude ID fields from display const columns = useMemo(() => { // Fields to hide in table view - const hiddenColumns = ['id', 'mandateId', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions']; + const hiddenColumns = ['id', 'mandateId', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions']; const cols = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) @@ -71,9 +71,9 @@ export const PromptsPage: React.FC = () => { fkDisplayField: (attr as any).fkDisplayField, })); - // Add _createdBy column with FK resolution to show username + // Add sysCreatedBy column with FK resolution to show username cols.push({ - key: '_createdBy', + key: 'sysCreatedBy', label: 'Created By', type: 'text' as any, sortable: true, @@ -148,7 +148,7 @@ export const PromptsPage: React.FC = () => { // Form attributes for create/edit modal const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'isSystem', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions']; + const excludedFields = ['id', 'mandateId', 'isSystem', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions']; return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/billing/Billing.module.css b/src/pages/billing/Billing.module.css index b0fa458..23bb649 100644 --- a/src/pages/billing/Billing.module.css +++ b/src/pages/billing/Billing.module.css @@ -101,7 +101,7 @@ margin: 0; } -.billingModel { +.mandateSubtitle { font-size: 0.75rem; color: var(--text-secondary, #888); background: var(--bg-secondary, #2a2a2a); diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index d317f51..07c3be0 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -85,10 +85,11 @@ interface SettingsEditorProps { const SettingsEditor: React.FC = ({ settings, onSave, loading }) => { const [formData, setFormData] = useState({ - billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'], - defaultUserCredit: Number(settings?.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10), notifyOnWarning: settings?.notifyOnWarning ?? true, + autoRechargeEnabled: settings?.autoRechargeEnabled ?? false, + rechargeAmountCHF: Number(settings?.rechargeAmountCHF ?? 10), + rechargeMaxPerMonth: Number(settings?.rechargeMaxPerMonth ?? 3), }); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -96,10 +97,11 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi useEffect(() => { if (settings) { setFormData({ - billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE', - defaultUserCredit: Number(settings.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), notifyOnWarning: settings.notifyOnWarning ?? true, + autoRechargeEnabled: settings.autoRechargeEnabled ?? false, + rechargeAmountCHF: Number(settings.rechargeAmountCHF ?? 10), + rechargeMaxPerMonth: Number(settings.rechargeMaxPerMonth ?? 3), }); } }, [settings]); @@ -130,32 +132,6 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi )}
-
-
- - -
- -
- - setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))} - min="0" - step="0.01" - /> -
-
-
@@ -184,6 +160,49 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi
+ +
+
+ +
+
+ {formData.autoRechargeEnabled && ( +
+
+ + + setFormData(prev => ({ ...prev, rechargeAmountCHF: Number(e.target.value) })) + } + min="0.01" + step="0.01" + /> +
+
+ + + setFormData(prev => ({ ...prev, rechargeMaxPerMonth: Math.max(0, Math.floor(Number(e.target.value))) })) + } + min="0" + step="1" + /> +
+
+ )} @@ -328,11 +309,12 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on interface AccountsOverviewProps { accounts: AccountSummary[]; - users: MandateUserSummary[]; + /** Kept for call-site compatibility; only mandate pool accounts are shown. */ + users?: MandateUserSummary[]; loading: boolean; } -const AccountsOverview: React.FC = ({ accounts, users, loading }) => { +const AccountsOverview: React.FC = ({ accounts, loading }) => { const formatCurrency = (amount: number) => { return new Intl.NumberFormat('de-CH', { style: 'currency', @@ -340,19 +322,8 @@ const AccountsOverview: React.FC = ({ accounts, users, lo }).format(amount); }; - // Build a lookup map: userId -> display name - const _userNameMap = useMemo(() => { - const map = new Map(); - for (const user of users) { - const displayName = user.displayName - || [user.firstName, user.lastName].filter(Boolean).join(' ') - || user.username - || user.id; - map.set(user.id, displayName); - } - return map; - }, [users]); - + const poolAccounts = useMemo(() => accounts.filter((a) => !a.userId), [accounts]); + if (loading) { return
Lade Konten...
; } @@ -360,16 +331,19 @@ const AccountsOverview: React.FC = ({ accounts, users, lo if (accounts.length === 0) { return
Keine Konten vorhanden
; } + + if (poolAccounts.length === 0) { + return
Kein Mandanten-Konto vorhanden
; + } return (

Konten

- {accounts.map((account) => ( + {poolAccounts.map((account) => (
-

{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}

+

Mandanten-Konto

- {account.userId && User: {_userNameMap.get(account.userId) || account.userId}} Guthaben: {formatCurrency(account.balance)} Status: {account.enabled ? 'Aktiv' : 'Deaktiviert'}
@@ -782,9 +756,6 @@ export const BillingAdmin: React.FC = () => { <> {isSysAdmin && ( )} diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx index b61b536..7525836 100644 --- a/src/pages/billing/BillingDashboard.tsx +++ b/src/pages/billing/BillingDashboard.tsx @@ -26,11 +26,6 @@ const BalanceCard: React.FC = ({ balance, onClick }) => { }).format(amount); }; - const getBillingModelLabel = (model: string) => { - if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; - return 'Prepaid (Mandant)'; - }; - return (
= ({ balance, onClick }) => { >

{balance.mandateName}

- {getBillingModelLabel(balance.billingModel)}
{formatCurrency(balance.balance)} diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index feb44f1..65501a0 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -8,19 +8,16 @@ */ import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams, useNavigate } from 'react-router-dom'; import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport'; import api from '../../api'; -import { useApiRequest } from '../../hooks/useApi'; import { useBilling, type BillingBalance } from '../../hooks/useBilling'; -import { createCheckoutSession, UserTransaction } from '../../api/billingApi'; -import { getUserDataCache } from '../../utils/userCache'; +import { UserTransaction } from '../../api/billingApi'; +import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import styles from './Billing.module.css'; -const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500]; - // ============================================================================ // HELPER: Currency formatter // ============================================================================ @@ -46,34 +43,51 @@ interface ViewStatistics { timeSeries: Array<{ date: string; cost: number; count: number }>; } +interface DataVolumeInfo { + mandateId: string; + mandateName: string; + usedMB: number; + filesMB: number; + ragIndexMB: number; + maxDataVolumeMB: number | null; + percentUsed: number | null; + warning: boolean; +} + // ============================================================================ // BALANCE CARD COMPONENT // ============================================================================ interface BalanceCardProps { balance: BillingBalance; - onCheckout?: (mandateId: string, amount: number) => void; - checkoutLoading?: boolean; + onOpenMandateAdmin?: (mandateId: string) => void; } -const BalanceCard: React.FC = ({ balance, onCheckout, checkoutLoading }) => { - const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]); - const [showCheckout, setShowCheckout] = useState(false); - - const _getBillingModelLabel = (model: string) => { - if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; - return 'Prepaid (Mandant)'; - }; - - // Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing. - const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER'; - const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE'; - +const BalanceCard: React.FC = ({ balance, onOpenMandateAdmin }) => { return (
-

{balance.mandateName}

- {_getBillingModelLabel(balance.billingModel)} + {onOpenMandateAdmin ? ( + + ) : ( +

{balance.mandateName}

+ )}
{_formatCurrency(balance.balance)} @@ -83,60 +97,17 @@ const BalanceCard: React.FC = ({ balance, onCheckout, checkout Niedriges Guthaben
)} - {isMandatePrepaidPool && ( -

- Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing). -

- )} - {canStripeTopUpHere && onCheckout && ( -
- {!showCheckout ? ( - - ) : ( -
- - - -
- )} -
- )} +

+ Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing). +

); }; @@ -329,9 +300,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { export const BillingDataView: React.FC = () => { const [activeTab, setActiveTab] = useState('overview'); const [searchParams, setSearchParams] = useSearchParams(); - const { request } = useApiRequest(); - const [checkoutLoading, setCheckoutLoading] = useState(false); + const navigate = useNavigate(); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + const _openMandateBillingAdmin = useCallback((mandateId: string) => { + navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`); + }, [navigate]); // Scope filter: 'personal' | 'all' | mandateId const [selectedScope, setSelectedScope] = useState('personal'); @@ -399,58 +373,20 @@ export const BillingDataView: React.FC = () => { setCheckoutMessage(null); }, [searchParams, setSearchParams]); - const _handleCheckout = useCallback(async (mandateId: string, amount: number) => { - setCheckoutLoading(true); - setCheckoutMessage(null); - try { - const currentUser = getUserDataCache(); - const currentUrl = new URL(window.location.href); - currentUrl.searchParams.delete('success'); - currentUrl.searchParams.delete('canceled'); - currentUrl.searchParams.delete('session_id'); - currentUrl.hash = ''; - const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; - const result = await createCheckoutSession(request, mandateId, { - userId: currentUser?.id, - amount, - returnUrl, - }); - if (result?.redirectUrl) { - window.location.href = result.redirectUrl; - } - } catch (err: any) { - setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' }); - setCheckoutLoading(false); - } - }, [request]); - - // All user balances (for admin overview cards) - const [allUserBalances, setAllUserBalances] = useState([]); - const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); - // Statistics state (shared by Overview and Statistics tabs) const [viewStats, setViewStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); - + + // Storage volume state (for Statistics tab) + const [storageData, setStorageData] = useState([]); + const [storageLoading, setStorageLoading] = useState(false); + // Transactions state (for Transactions tab) const [transactions, setTransactions] = useState([]); const [transactionsLoading, setTransactionsLoading] = useState(false); const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(null); - // Load all user balances for admin overview - const _loadAllUserBalances = useCallback(async () => { - try { - setAllUserBalancesLoading(true); - const response = await api.get('/api/billing/view/users/balances'); - setAllUserBalances(Array.isArray(response.data) ? response.data : []); - } catch { - setAllUserBalances([]); - } finally { - setAllUserBalancesLoading(false); - } - }, []); - // Load aggregated statistics from the view/statistics route const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => { try { @@ -486,15 +422,47 @@ export const BillingDataView: React.FC = () => { _loadViewStatistics(period, year, month); }, [_loadViewStatistics]); - // Initial data load: load statistics when overview or statistics tab becomes active + // Load storage volume for all accessible mandates + const _loadStorageData = useCallback(async () => { + const mandateIds = new Set(); + for (const b of balances) { + if (selectedScope === 'personal' || selectedScope === 'all' || selectedScope === b.mandateId) { + mandateIds.add(b.mandateId); + } + } + if (mandateIds.size === 0) { + setStorageData([]); + return; + } + + setStorageLoading(true); + try { + const mandateNameMap = new Map(balances.map(b => [b.mandateId, b.mandateName])); + const results = await Promise.all( + Array.from(mandateIds).map(async (mid) => { + try { + const resp = await api.get(`/api/subscription/data-volume/${mid}`); + return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo; + } catch { + return null; + } + }) + ); + setStorageData(results.filter((r): r is DataVolumeInfo => r !== null)); + } catch { + setStorageData([]); + } finally { + setStorageLoading(false); + } + }, [balances, selectedScope]); + + // Initial data load useEffect(() => { if (activeTab === 'overview' || activeTab === 'statistics') { _loadViewStatistics('month', new Date().getFullYear()); + _loadStorageData(); } - if (activeTab === 'overview') { - _loadAllUserBalances(); - } - }, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]); + }, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]); // Load transactions with pagination support const _loadTransactions = useCallback(async (paginationParams?: any) => { @@ -644,12 +612,6 @@ export const BillingDataView: React.FC = () => { const filteredBalances = selectedScope === 'personal' || selectedScope === 'all' ? balances : balances.filter(b => b.mandateId === selectedScope); - - const filteredUserBalances = selectedScope === 'personal' - ? [] // personal view: only own balance cards, no other users - : selectedScope === 'all' - ? allUserBalances - : allUserBalances.filter(ub => ub.mandateId === selectedScope); return ( <> @@ -666,35 +628,60 @@ export const BillingDataView: React.FC = () => { ))}
)} - {/* All User Balance Cards (mandate/all scope) */} - {filteredUserBalances.length > 0 && ( + {/* Storage quick info */} + {!storageLoading && storageData.length > 0 && (
-

Benutzer-Guthaben

- {allUserBalancesLoading ? ( -
Lade Benutzer-Guthaben...
- ) : ( -
- {filteredUserBalances.map((ub, idx) => ( -
-
-

{ub.userName || ub.userId?.slice(0, 8)}

- {ub.mandateName} -
-
- {_formatCurrency(ub.balance || 0)} +

Speicher

+
+ {storageData.map((sv) => { + const pct = sv.percentUsed ?? 0; + const barColor = pct >= 90 + ? 'var(--color-error, #ef4444)' + : pct >= 70 + ? 'var(--color-warning, #f59e0b)' + : 'var(--primary-color, #3b82f6)'; + return ( +
+

{sv.mandateName}

+
+ {formatBinaryDataSizeFromMebibytes(sv.usedMB)} + + / {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'} +
+ {sv.maxDataVolumeMB != null && ( +
+
0 ? '3px' : '0', + }} /> +
+ )} + {sv.warning && ( +
+ Speicher knapp +
+ )}
- ))} -
- )} + ); + })} +
)} @@ -716,18 +703,104 @@ export const BillingDataView: React.FC = () => { {/* Tab: Statistik (Dashboard) */} {/* ================================================================ */} {activeTab === 'statistics' && ( -
- -
+ <> + {/* Storage volume section */} +
+
+

+ Speicherverbrauch +

+ {storageLoading ? ( +
Lade Speicherdaten...
+ ) : storageData.length === 0 ? ( +
Keine Speicherdaten verfügbar
+ ) : ( +
+ {storageData.map((sv) => { + const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB); + const maxLabel = sv.maxDataVolumeMB != null + ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) + : 'unbegrenzt'; + const pct = sv.percentUsed ?? 0; + const barColor = pct >= 90 + ? 'var(--color-error, #ef4444)' + : pct >= 70 + ? 'var(--color-warning, #f59e0b)' + : 'var(--primary-color, #3b82f6)'; + + return ( +
+
+ + {sv.mandateName} + + + {usedLabel} / {maxLabel} + {sv.percentUsed != null && ( + + ({pct.toFixed(1)}%) + + )} + +
+ {sv.maxDataVolumeMB != null && ( +
+
0 ? '4px' : '0', + }} /> +
+ )} +
+ Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)} + RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)} +
+
+ ); + })} +
+ )} +
+
+ + {/* AI usage statistics */} +
+ +
+ )} {/* ================================================================ */} diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index 3279505..b85f149 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -38,20 +38,14 @@ const MandateBalanceTable: React.FC = ({ }).format(amount); }; - const getBillingModelLabel = (model: string) => { - if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; - return 'Prepaid (Mandant)'; - }; - return (
- - + @@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC = ({ className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''} > - - +
MandantBilling-Modell Anzahl BenutzerStandard-GuthabenWarnschwelle (%) Gesamtguthaben Aktion
{balance.mandateName || balance.mandateId}{getBillingModelLabel(balance.billingModel)} {balance.userCount}{formatCurrency(balance.defaultUserCredit)}{balance.warningThresholdPercent}% {formatCurrency(balance.totalBalance)} + {!udbCollapsed && ( + + )} + + )} + + {/* Main Content */} +
{/* Context Selector */}
{coach.contexts.map(ctx => ( @@ -286,13 +271,13 @@ export const CommcoachDossierView: React.FC = () => { {/* Tab Navigation */}
- {(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => ( + {(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => ( ))}
@@ -546,41 +531,9 @@ export const CommcoachDossierView: React.FC = () => {
)} - {/* ============================================================ */} - {/* DOCUMENTS TAB */} - {/* ============================================================ */} - {activeTab === 'documents' && ( -
-
- -
- {documents.length === 0 ? ( -
Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.
- ) : ( -
- {documents.map(doc => ( -
-
-
{doc.fileName}
-
- {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''} -
- {doc.summary &&
{doc.summary}
} -
-
- - -
-
- ))} -
- )} -
- )} )} + + {/* #region agent log */}
{/* #endregion */}
+ ); }; @@ -607,13 +561,12 @@ function _categoryIcon(category: string): string { return icons[category] || '*'; } -function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string { +function _tabLabel(tab: TabKey, coach: any): string { switch (tab) { case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching'; case 'tasks': return `Aufgaben (${coach.tasks.length})`; case 'sessions': return `Sessions (${coach.sessions.length})`; case 'scores': return `Bewertungen (${coach.scores.length})`; - case 'documents': return `Dokumente (${documents.length})`; } } @@ -634,12 +587,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] { return Object.values(groups); } -function _formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - function _dimensionLabel(dim: string): string { const labels: Record = { empathy: 'Einfühlungsvermögen', clarity: 'Klarheit', diff --git a/src/pages/views/commcoach/CommcoachSettingsView.tsx b/src/pages/views/commcoach/CommcoachSettingsView.tsx index b7af4cd..f7c056a 100644 --- a/src/pages/views/commcoach/CommcoachSettingsView.tsx +++ b/src/pages/views/commcoach/CommcoachSettingsView.tsx @@ -1,15 +1,16 @@ /** * CommCoach Settings View - * - * User profile settings: voice preferences, reminders, email notifications. + * + * Coaching-specific settings: reminders, email notifications, stats. + * Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { getProfileApi, updateProfileApi, - getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi, type CoachingUserProfile, } from '../../../api/commcoachApi'; import styles from './CommcoachSettingsView.module.css'; @@ -19,16 +20,11 @@ export const CommcoachSettingsView: React.FC = () => { const instanceId = useInstanceId(); const [profile, setProfile] = useState(null); - const [languages, setLanguages] = useState([]); - const [voices, setVoices] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [language, setLanguage] = useState('de-DE'); - const [voiceId, setVoiceId] = useState(''); const [reminderEnabled, setReminderEnabled] = useState(false); const [reminderTime, setReminderTime] = useState('09:00'); const [emailEnabled, setEmailEnabled] = useState(true); @@ -38,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => { const loadData = async () => { setLoading(true); try { - const [profileData, languagesData] = await Promise.all([ - getProfileApi(request, instanceId), - getVoiceLanguagesApi(request, instanceId), - ]); + const profileData = await getProfileApi(request, instanceId); setProfile(profileData); - setLanguages(languagesData || []); - if (profileData) { - setLanguage(profileData.preferredLanguage || 'de-DE'); - setVoiceId(profileData.preferredVoice || ''); setReminderEnabled(profileData.dailyReminderEnabled || false); setReminderTime(profileData.dailyReminderTime || '09:00'); setEmailEnabled(profileData.emailSummaryEnabled !== false); } - - const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE'); - setVoices(voicesData || []); } catch (err: any) { setError(err.message || 'Fehler beim Laden'); } finally { @@ -64,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => { loadData(); }, [request, instanceId]); - const handleLanguageChange = useCallback(async (newLang: string) => { - setLanguage(newLang); - if (!instanceId) return; - try { - const voicesData = await getVoiceVoicesApi(request, instanceId, newLang); - setVoices(voicesData || []); - setVoiceId(''); - } catch { /* ignore */ } - }, [request, instanceId]); - const handleSave = useCallback(async () => { if (!instanceId) return; setSaving(true); @@ -81,8 +57,6 @@ export const CommcoachSettingsView: React.FC = () => { setSuccess(null); try { const updated = await updateProfileApi(request, instanceId, { - preferredLanguage: language, - preferredVoice: voiceId || null, dailyReminderEnabled: reminderEnabled, dailyReminderTime: reminderTime, emailSummaryEnabled: emailEnabled, @@ -95,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => { } finally { setSaving(false); } - }, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]); - - const handleTestVoice = useCallback(async () => { - if (!instanceId) return; - setTesting(true); - try { - const result = await testVoiceApi(request, instanceId, { - language, - voiceId: voiceId || undefined, - }); - if (result.success && result.audio) { - const audioData = `data:audio/mp3;base64,${result.audio}`; - const audio = new Audio(audioData); - audio.play(); - } - } catch (err: any) { - setError('Sprachtest fehlgeschlagen'); - } finally { - setTesting(false); - } - }, [request, instanceId, language, voiceId]); + }, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]); if (loading) { return
Einstellungen werden geladen...
; @@ -128,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => { {error &&
{error}
} {success &&
{success}
} - {/* Voice Settings */}
-

Sprache und Stimme

- -
- - -
- -
- -
- - -
-
+

Stimme & Sprache

+

+ Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert. +

+ {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}> + Benutzereinstellungen oeffnen (Tab "Stimme & Sprache") +
- {/* Reminder Settings */}

Erinnerungen

-
- {reminderEnabled && (
- setReminderTime(e.target.value)} - /> + setReminderTime(e.target.value)} />
)} -
- {/* Stats */} {profile && (

Statistik

-
- {profile.totalSessions} - Sessions gesamt -
-
- {profile.totalMinutes} - Minuten gesamt -
-
- {profile.streakDays} - Aktueller Streak -
-
- {profile.longestStreak} - Laengster Streak -
+
{profile.totalSessions}Sessions gesamt
+
{profile.totalMinutes}Minuten gesamt
+
{profile.streakDays}Aktueller Streak
+
{profile.longestStreak}Laengster Streak
)} diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts index 142d4d6..5e9e8c8 100644 --- a/src/pages/views/commcoach/useVoiceController.ts +++ b/src/pages/views/commcoach/useVoiceController.ts @@ -6,10 +6,12 @@ * * Uses the generic useVoiceStream hook for mic capture + STT streaming. * Google Streaming STT handles silence detection natively. + * STT language is loaded from central voice preferences (/api/voice/preferences). */ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; +import api from '../../../api'; export type VoiceState = 'idle' | 'listening' | 'botSpeaking' | 'interrupted'; @@ -30,6 +32,8 @@ export interface VoiceControllerCallbacks { onInterimText?: (text: string) => void; } +const _DEFAULT_STT_LANGUAGE = 'de-DE'; + export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceControllerApi { const [state, setState] = useState('idle'); const [muted, setMuted] = useState(false); @@ -38,6 +42,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo const cbRef = useRef(callbacks); cbRef.current = callbacks; + const sttLanguageRef = useRef(_DEFAULT_STT_LANGUAGE); + + useEffect(() => { + let cancelled = false; + api.get('/api/voice/preferences').then((res) => { + if (cancelled) return; + const lang = res.data?.sttLanguage || res.data?.ttsLanguage; + if (lang) sttLanguageRef.current = lang; + }).catch(() => {}); + return () => { cancelled = true; }; + }, []); + const _dlog = useCallback((tag: string, info?: string) => { const t = new Date(); const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2, '0')}.${String(t.getMilliseconds()).padStart(3, '0')}`; @@ -68,16 +84,20 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo onError: (err) => _dlog('VOICE-ERR', String(err)), }); + const _startStream = useCallback(() => { + return voiceStream.start(sttLanguageRef.current); + }, [voiceStream]); + const activate = useCallback(async () => { if (stateRef.current !== 'idle') return; _setState('listening'); try { - await voiceStream.start('de-DE'); + await _startStream(); } catch (err) { _dlog('MIC-ERR', String(err)); _setState('idle'); } - }, [_setState, voiceStream, _dlog]); + }, [_setState, _startStream, _dlog]); const deactivate = useCallback(() => { voiceStream.stop(); @@ -94,15 +114,15 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo const ttsPaused = useCallback(() => { if (stateRef.current !== 'botSpeaking') return; _setState('interrupted'); - voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); - }, [_setState, voiceStream, _dlog]); + _startStream().catch((err) => _dlog('MIC-ERR', String(err))); + }, [_setState, _startStream, _dlog]); const ttsEnded = useCallback(() => { const cur = stateRef.current; if (cur !== 'botSpeaking' && cur !== 'interrupted') return; _setState('listening'); - voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); - }, [_setState, voiceStream, _dlog]); + _startStream().catch((err) => _dlog('MIC-ERR', String(err))); + }, [_setState, _startStream, _dlog]); const toggleMute = useCallback(() => { const cur = stateRef.current; @@ -110,13 +130,13 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo if (mutedRef.current) { _setMuted(false); if (cur === 'listening' || cur === 'interrupted') { - voiceStream.start('de-DE').catch((err) => _dlog('MIC-ERR', String(err))); + _startStream().catch((err) => _dlog('MIC-ERR', String(err))); } } else { _setMuted(true); voiceStream.stop(); } - }, [_setMuted, voiceStream, _dlog]); + }, [_setMuted, _startStream, voiceStream, _dlog]); return { state, diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 939af4f..d93c472 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -110,7 +110,7 @@ export const RealEstateParcelsView: React.FC = () => { }; const formAttributes = useMemo(() => { - const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excluded.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 63ac061..25085e2 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -106,7 +106,7 @@ export const RealEstateProjectsView: React.FC = () => { }; const formAttributes = useMemo(() => { - const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excluded.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index 6cac4db..7def6c7 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -150,7 +150,7 @@ export const TrusteeDocumentsView: React.FC = () => { // Form attributes (exclude system fields) const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index b514d48..5aaecfa 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -52,7 +52,7 @@ export const TrusteePositionDocumentsView: React.FC = () => { if (!attributes || attributes.length === 0) return []; // Exclude system fields from table columns - const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return attributes .filter((attr: any) => !excludedFields.includes(attr.name)) @@ -127,7 +127,7 @@ export const TrusteePositionDocumentsView: React.FC = () => { // Form attributes (exclude system fields) const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter((attr: any) => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 7cf9e43..e60b9b6 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -257,7 +257,7 @@ export const TrusteePositionsView: React.FC = () => { const positionColumnOrder = [ '_documentRefs', // Belege (download icons) '_syncStatus', // Sync-Status - '_createdAt', // Erstellt am + 'sysCreatedAt', // Erstellt am 'valuta', // Valuta date 'tags', 'company', @@ -372,7 +372,7 @@ export const TrusteePositionsView: React.FC = () => { // Form attributes (exclude system fields) const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx index 0b5c330..a66bc3b 100644 --- a/src/pages/views/workspace/ChatStream.tsx +++ b/src/pages/views/workspace/ChatStream.tsx @@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import api from '../../../api'; +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { AgentProgress, FileEditProposal } from './useWorkspace'; @@ -147,6 +148,44 @@ export const ChatStream: React.FC = ({ charCount={(msg as any)._audioCharCount} /> )} + {msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && ( +
+ + Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'}) + +
+ {msg.documents.map((doc, idx) => ( +
+ + {doc.documentName || doc.fileName || `Dokument ${idx + 1}`} + + {doc.validationMetadata?.neutralized && ( + + neutralisiert + + )} + {doc.validationMetadata?.skipped && ( + + übersprungen + + )} +
+ ))} +
+ {(msg as any).neutralizationExcluded?.length > 0 && ( +
+
+ Nicht gesendet (Neutralisierung fehlgeschlagen): +
+ {(msg as any).neutralizationExcluded.map((docName: string, i: number) => ( +
+ {docName} +
+ ))} +
+ )} +
+ )} )} @@ -301,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) { const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || ''; const icon = _getFileIcon(ext); - const sizeLabel = doc.fileSize - ? doc.fileSize > 1024 * 1024 - ? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB` - : `${(doc.fileSize / 1024).toFixed(1)} KB` - : ''; + const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : ''; return (
void; - onCreateNew?: () => void; - refreshTrigger?: number; -} - -export const ConversationList: React.FC = ({ - instanceId, - activeWorkflowId, - onSelect, - onCreateNew, - refreshTrigger, -}) => { - const [conversations, setConversations] = useState([]); - const [loading, setLoading] = useState(false); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [filterQuery, setFilterQuery] = useState(''); - const [page, setPage] = useState(0); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [viewMode, setViewMode] = useState<'active' | 'archived'>('active'); - const inputRef = useRef(null); - - const _loadConversations = useCallback(() => { - if (!instanceId) return; - setLoading(true); - api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } }) - .then(res => { - const items = (res.data.workflows || res.data || []) - .map((w: any) => ({ - id: w.id, - name: w.name || w.label || 'Untitled', - status: w.status || 'unknown', - startedAt: w.startedAt || w.createdAt, - lastActivity: w.lastActivity || w.updatedAt || w.startedAt, - })) - .sort((a: Conversation, b: Conversation) => - (b.lastActivity || 0) - (a.lastActivity || 0), - ); - setConversations(items); - }) - .catch(() => setConversations([])) - .finally(() => setLoading(false)); - }, [instanceId]); - - useEffect(() => { - _loadConversations(); - }, [_loadConversations]); - - useEffect(() => { - if (refreshTrigger) _loadConversations(); - }, [refreshTrigger, _loadConversations]); - - useEffect(() => { - if (activeWorkflowId && !conversations.find(c => c.id === activeWorkflowId)) { - _loadConversations(); - } - }, [activeWorkflowId, conversations, _loadConversations]); - - useEffect(() => { - if (editingId && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [editingId]); - - const _formatTime = (ts?: number): string => { - if (!ts) return ''; - const d = new Date(ts * 1000); - const now = new Date(); - const diffMs = now.getTime() - d.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) { - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - if (diffDays === 1) return 'Gestern'; - if (diffDays < 7) return `vor ${diffDays}d`; - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); - }; - - const _formatDate = (ts?: number): string => { - if (!ts) return ''; - const d = new Date(ts * 1000); - return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' }) - + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }; - - const _startEditing = (conv: Conversation) => { - setEditingId(conv.id); - setEditName(conv.name); - }; - - const _commitRename = (convId: string) => { - const trimmed = editName.trim(); - if (!trimmed) { - setEditingId(null); - return; - } - setConversations(prev => - prev.map(c => c.id === convId ? { ...c, name: trimmed } : c), - ); - setEditingId(null); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed }) - .catch(() => _loadConversations()); - }; - - const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => { - if (e.key === 'Enter') { - e.preventDefault(); - _commitRename(convId); - } else if (e.key === 'Escape') { - setEditingId(null); - } - }; - - const _handleDelete = (convId: string) => { - setConversations(prev => prev.filter(c => c.id !== convId)); - if (activeWorkflowId === convId) onSelect(''); - api.delete(`/api/workspace/${instanceId}/workflows/${convId}`) - .catch(() => _loadConversations()); - }; - - const _handleArchive = (convId: string) => { - setConversations(prev => prev.map(c => - c.id === convId ? { ...c, status: 'archived' } : c, - )); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' }) - .catch(() => _loadConversations()); - }; - - const _handleReactivate = (convId: string) => { - setConversations(prev => prev.map(c => - c.id === convId ? { ...c, status: 'active' } : c, - )); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' }) - .catch(() => _loadConversations()); - }; - - const _handleCreateNew = () => { - if (onCreateNew) onCreateNew(); - }; - - const _filtered = (items: Conversation[], query: string): Conversation[] => { - if (!query.trim()) return items; - const q = query.toLowerCase(); - return items.filter(c => - c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q), - ); - }; - - const _byStatus = viewMode === 'archived' - ? conversations.filter(c => c.status === 'archived') - : conversations.filter(c => c.status !== 'archived'); - const filtered = _filtered(_byStatus, filterQuery); - const totalPages = Math.ceil(filtered.length / _PAGE_SIZE); - const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE); - - const _archivedCount = conversations.filter(c => c.status === 'archived').length; - const _activeCount = conversations.filter(c => c.status !== 'archived').length; - - useEffect(() => { setPage(0); }, [filterQuery, viewMode]); - - return ( -
- {/* Header */} -
- Conversations -
- - -
-
- - {/* View mode toggle */} -
- - -
- - {/* Filter */} - {filtered.length > 3 && ( - setFilterQuery(e.target.value)} - style={{ - width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, - border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', - }} - /> - )} - - {/* Empty state */} - {filtered.length === 0 && !loading && ( -
- {viewMode === 'archived' - ? 'Keine archivierten Chats.' - : 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'} -
- )} - - {/* List */} - {paginated.map(conv => { - const isActive = conv.id === activeWorkflowId; - const isEditing = editingId === conv.id; - return ( -
{ if (!isEditing) onSelect(conv.id); }} - style={{ - padding: '8px 10px', - marginBottom: 4, - borderRadius: 6, - cursor: isEditing ? 'default' : 'pointer', - background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent', - border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent', - transition: 'background 0.15s', - position: 'relative', - }} - onMouseEnter={e => { - if (!isActive) e.currentTarget.style.background = '#f5f5f5'; - const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; - if (actions) actions.style.opacity = '1'; - }} - onMouseLeave={e => { - if (!isActive) e.currentTarget.style.background = 'transparent'; - const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; - if (actions) actions.style.opacity = '0'; - if (confirmDeleteId === conv.id) setConfirmDeleteId(null); - }} - > - {/* Name row */} -
- {isEditing ? ( - setEditName(e.target.value)} - onBlur={() => _commitRename(conv.id)} - onKeyDown={e => _handleKeyDown(e, conv.id)} - onClick={e => e.stopPropagation()} - style={{ - flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, - padding: '1px 4px', borderRadius: 3, - border: '1px solid var(--primary-color, #1976d2)', - outline: 'none', background: '#fff', - }} - /> - ) : ( - <> - - {_formatTime(conv.lastActivity)} - - { e.stopPropagation(); _startEditing(conv); }} - title={conv.name} - > - {conv.name} - - - )} - - {/* Action buttons (visible on hover) */} - {!isEditing && ( - - - {conv.status === 'archived' ? ( - - ) : ( - - )} - {confirmDeleteId === conv.id ? ( - - - - - ) : ( - - )} - - )} -
- -
- ); - })} - - {/* Pagination */} - {totalPages > 1 && ( -
- - {page + 1} / {totalPages} - -
- )} -
- ); -}; - -const _actionBtnStyle: React.CSSProperties = { - background: 'none', - border: 'none', - cursor: 'pointer', - fontSize: 11, - color: '#999', - padding: '0 2px', -}; - -const _pageBtnStyle: React.CSSProperties = { - background: 'none', - border: '1px solid #ddd', - borderRadius: 4, - cursor: 'pointer', - padding: '2px 8px', - color: '#666', -}; diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx deleted file mode 100644 index 1ce992c..0000000 --- a/src/pages/views/workspace/FileBrowser.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** - * FileBrowser -- Folder-tree file browser for workspace. - * - * Uses useFileContext() for folders (shared state with Dateien page). - * Uses FolderTree with showFiles=true so folders and files render inline. - */ - -import React, { useState, useCallback, useRef, useMemo } from 'react'; -import api from '../../../api'; -import FolderTree from '../../../components/FolderTree/FolderTree'; -import type { FileNode } from '../../../components/FolderTree/FolderTree'; -import { useFileContext } from '../../../contexts/FileContext'; -import type { WorkspaceFile } from './useWorkspace'; - -interface FileBrowserProps { - instanceId: string; - files: WorkspaceFile[]; - onRefresh: () => void; - onFileSelect?: (fileId: string) => void; -} - -export const FileBrowser: React.FC = ({ - instanceId, - files, - onRefresh, - onFileSelect, -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [isDragOver, setIsDragOver] = useState(false); - const [uploading, setUploading] = useState(false); - const [selectedFolderId, setSelectedFolderId] = useState(null); - const fileInputRef = useRef(null); - - const { - folders, - refreshFolders, - handleCreateFolder, - handleRenameFolder, - handleDeleteFolder, - handleMoveFolder, - handleMoveFolders, - handleMoveFile, - handleMoveFiles: contextMoveFiles, - handleFileDelete, - handleDownloadFolder, - expandedFolderIds, - toggleFolderExpanded, - } = useFileContext(); - - const _folderNodes = useMemo(() => - folders.map(f => ({ - id: f.id, - name: f.name, - parentId: f.parentId ?? null, - })), - [folders], - ); - - const _fileNodes: FileNode[] = useMemo(() => { - let result: WorkspaceFile[] = files; - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter(f => - f.fileName.toLowerCase().includes(q) - || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)), - ); - } - return result - .sort((a, b) => a.fileName.localeCompare(b.fileName)) - .map(f => ({ - id: f.id, - fileName: f.fileName, - mimeType: f.mimeType, - fileSize: f.fileSize, - folderId: f.folderId ?? null, - })); - }, [files, searchQuery]); - - const _refreshAll = useCallback(() => { - onRefresh(); - refreshFolders(); - }, [onRefresh, refreshFolders]); - - const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { - if (!instanceId || uploading) return; - setUploading(true); - try { - for (const file of Array.from(fileList)) { - const formData = new FormData(); - formData.append('file', file); - formData.append('featureInstanceId', instanceId); - await api.post('/api/files/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - } - _refreshAll(); - } catch (err) { - console.error('File upload failed:', err); - } finally { - setUploading(false); - } - }, [instanceId, uploading, _refreshAll]); - - const _handleDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('Files')) { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - } - }, []); - - const _handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }, []); - - const _handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - if (e.dataTransfer.files.length > 0) { - _uploadFiles(e.dataTransfer.files); - } - }, [_uploadFiles]); - - const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - _uploadFiles(e.target.files); - e.target.value = ''; - } - }, [_uploadFiles]); - - const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { - await handleMoveFile(fileId, targetFolderId); - onRefresh(); - }, [handleMoveFile, onRefresh]); - - const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { - await contextMoveFiles(fileIds, targetFolderId); - onRefresh(); - }, [contextMoveFiles, onRefresh]); - - const _onDeleteFolder = useCallback(async (folderId: string) => { - await handleDeleteFolder(folderId); - if (selectedFolderId === folderId) setSelectedFolderId(null); - onRefresh(); - }, [handleDeleteFolder, selectedFolderId, onRefresh]); - - const _onRenameFile = useCallback(async (fileId: string, newName: string) => { - await api.put(`/api/files/${fileId}`, { fileName: newName }); - onRefresh(); - }, [onRefresh]); - - const _onDeleteFile = useCallback(async (fileId: string) => { - await handleFileDelete(fileId); - onRefresh(); - }, [handleFileDelete, onRefresh]); - - const _onDeleteFiles = useCallback(async (fileIds: string[]) => { - await api.post('/api/files/batch-delete', { fileIds }); - onRefresh(); - }, [onRefresh]); - - const _onDeleteFolders = useCallback(async (folderIds: string[]) => { - await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); - refreshFolders(); - onRefresh(); - }, [refreshFolders, onRefresh]); - - return ( -
- {isDragOver && ( -
- Dateien hier ablegen -
- )} - - {/* Header */} -
- Files -
- - -
-
- - - - {/* Search */} - setSearchQuery(e.target.value)} - style={{ - width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, - border: '1px solid #ddd', boxSizing: 'border-box', - }} - /> - - {/* Folder tree with inline files */} - - - {_fileNodes.length === 0 && ( -
- {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'} -
- )} -
- ); -}; diff --git a/src/pages/views/workspace/FilePreview.tsx b/src/pages/views/workspace/FilePreview.tsx index 7f618e0..1a99bc2 100644 --- a/src/pages/views/workspace/FilePreview.tsx +++ b/src/pages/views/workspace/FilePreview.tsx @@ -10,6 +10,7 @@ import React, { useState, useEffect } from 'react'; import api from '../../../api'; +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; import type { WorkspaceFile } from './useWorkspace'; interface FilePreviewProps { @@ -76,7 +77,7 @@ export const FilePreview: React.FC = ({ instanceId, fileId, fi
{file.mimeType} - {_formatFileSize(file.fileSize)} + {formatBinaryDataSizeBytes(file.fileSize)} {file.status && {file.status}}
{file.description && ( @@ -156,8 +157,3 @@ function _isTextMime(mime: string): boolean { return textTypes.includes(mime); } -function _formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx new file mode 100644 index 0000000..c8e1179 --- /dev/null +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -0,0 +1,464 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import api from '../../../api'; + +const _chatPromptSourceId = '__chat_prompt__'; +const _placeholderRx = /\[([a-z]+)\.([a-f0-9-]{36})\]/g; + +interface NeutralizationMapping { + id: string; + originalText: string; + placeholder: string; + patternType: string; + fileId?: string; + fileName?: string; + createdAt?: string; +} + +interface NeutralizationSnapshot { + id: string; + sourceLabel: string; + neutralizedText: string; + placeholderCount: number; +} + +interface NeutralizationSource { + fileId: string; + fileName: string; + neutralizationStatus: string; + mappingCount: number; + isVirtual?: boolean; +} + +interface NeutralizationPanelProps { + instanceId: string; +} + +function _normalizeApiRow(raw: Record): NeutralizationMapping { + const id = String(raw.id ?? ''); + const patternType = String(raw.patternType ?? 'unknown'); + const existingPh = raw.placeholder; + const placeholder = + typeof existingPh === 'string' && existingPh + ? existingPh + : id + ? `[${patternType}.${id}]` + : ''; + return { + id, + originalText: String(raw.originalText ?? ''), + placeholder, + patternType, + fileId: raw.fileId != null && raw.fileId !== '' ? String(raw.fileId) : undefined, + fileName: raw.fileName != null ? String(raw.fileName) : undefined, + createdAt: + raw.createdAt != null + ? String(raw.createdAt) + : raw.sysCreatedAt != null + ? String(raw.sysCreatedAt) + : undefined, + }; +} + +function _partitionAttributes(rows: unknown[]): { + byFile: Record; + unscoped: NeutralizationMapping[]; +} { + const byFile: Record = {}; + const unscoped: NeutralizationMapping[] = []; + for (const item of rows) { + if (!item || typeof item !== 'object') continue; + const raw = item as Record; + const m = _normalizeApiRow(raw); + const fid = raw.fileId; + if (fid == null || fid === '') { + unscoped.push(m); + } else { + const key = String(fid); + if (!byFile[key]) byFile[key] = []; + byFile[key].push(m); + } + } + return { byFile, unscoped }; +} + +const _phTypeColors: Record = { + name: '#7c3aed', + email: '#2563eb', + phone: '#0891b2', + address: '#059669', + financial: '#d97706', + id: '#dc2626', + logic: '#be185d', + company: '#4f46e5', + product: '#7c3aed', + location: '#059669', + other: '#6b7280', +}; + +function _renderHighlightedText( + text: string, + mappingLookup: Map, +): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIdx = 0; + const rx = new RegExp(_placeholderRx.source, 'g'); + let match: RegExpExecArray | null; + + while ((match = rx.exec(text)) !== null) { + if (match.index > lastIdx) { + parts.push({text.slice(lastIdx, match.index)}); + } + const phType = match[1]; + const phId = match[2]; + const fullPh = match[0]; + const mapping = mappingLookup.get(phId); + const color = _phTypeColors[phType] || _phTypeColors.other; + parts.push( + + {fullPh} + , + ); + lastIdx = match.index + match[0].length; + } + if (lastIdx < text.length) { + parts.push({text.slice(lastIdx)}); + } + return parts; +} + +const NeutralizationPanel: React.FC = ({ instanceId }) => { + const [sources, setSources] = useState([]); + const [selectedSource, setSelectedSource] = useState(null); + const [mappings, setMappings] = useState([]); + const [loading, setLoading] = useState(true); + const [attributeByFile, setAttributeByFile] = useState>({}); + const [attributeUnscoped, setAttributeUnscoped] = useState([]); + const [snapshots, setSnapshots] = useState([]); + const [expandedSnapshot, setExpandedSnapshot] = useState(null); + + const _mappingLookup = useMemo(() => { + const map = new Map(); + for (const m of attributeUnscoped) map.set(m.id, m); + for (const arr of Object.values(attributeByFile)) { + for (const m of arr) map.set(m.id, m); + } + return map; + }, [attributeUnscoped, attributeByFile]); + + const _loadSources = useCallback(async () => { + setLoading(true); + try { + const headers = instanceId ? { 'X-Instance-Id': instanceId } : undefined; + const [filesResponse, attrResponse] = await Promise.all([ + api.get(`/api/workspace/${instanceId}/files`, { headers }), + api.get('/api/neutralization/attributes', { headers }), + ]); + + let snapAxios: { data: unknown } = { data: [] }; + try { + const _enc = encodeURIComponent(instanceId); + snapAxios = await api.get(`/api/neutralization/${_enc}/snapshots`, { headers }); + } catch (_snapErr) { + console.warn('NeutralizationPanel: scoped snapshots failed, trying unscoped:', _snapErr); + try { + snapAxios = await api.get('/api/neutralization/snapshots', { headers }); + } catch (_snapErr2) { + console.error('NeutralizationPanel: snapshots could not be loaded:', _snapErr2); + snapAxios = { data: [] }; + } + } + + const rawFiles = filesResponse.data; + const files = Array.isArray(rawFiles) ? rawFiles : (rawFiles?.files || rawFiles?.data || []); + const fileList = Array.isArray(files) ? files : []; + + const attrPayload = attrResponse.data?.data ?? attrResponse.data ?? []; + const attrRows = Array.isArray(attrPayload) ? attrPayload : []; + const { byFile, unscoped } = _partitionAttributes(attrRows); + setAttributeByFile(byFile); + setAttributeUnscoped(unscoped); + + const _snapBody = snapAxios.data as { data?: unknown } | unknown[] | null | undefined; + const snapPayload = + Array.isArray(_snapBody) ? _snapBody : (_snapBody && typeof _snapBody === 'object' && 'data' in _snapBody + ? ( _snapBody as { data: unknown }).data + : _snapBody) ?? []; + const snapList: NeutralizationSnapshot[] = Array.isArray(snapPayload) ? (snapPayload as NeutralizationSnapshot[]) : []; + setSnapshots(snapList); + if (snapList.length > 0 && snapList[0].id) { + setExpandedSnapshot(snapList[0].id); + } else { + setExpandedSnapshot(null); + } + + const neutralizedFiles = fileList.filter((f: Record) => f.neutralize); + + const nextSources: NeutralizationSource[] = []; + if (unscoped.length > 0) { + nextSources.push({ + fileId: _chatPromptSourceId, + fileName: 'Chat, Prompt & Kontext', + neutralizationStatus: 'completed', + mappingCount: unscoped.length, + isVirtual: true, + }); + } + for (const f of neutralizedFiles) { + const fid = String(f.id ?? ''); + if (!fid) continue; + nextSources.push({ + fileId: fid, + fileName: String(f.fileName ?? f.name ?? 'unknown'), + neutralizationStatus: String(f.neutralizationStatus ?? f.status ?? 'unknown'), + mappingCount: byFile[fid]?.length ?? 0, + }); + } + setSources(nextSources); + } catch (err) { + console.error('Failed to load neutralization sources:', err); + } finally { + setLoading(false); + } + }, [instanceId]); + + useEffect(() => { + _loadSources(); + }, [_loadSources]); + + useEffect(() => { + if (!selectedSource) { + setMappings([]); + return; + } + if (selectedSource === _chatPromptSourceId) { + setMappings(attributeUnscoped); + return; + } + setMappings(attributeByFile[selectedSource] ?? []); + }, [selectedSource, attributeByFile, attributeUnscoped]); + + const _handleDeleteMapping = async (mappingId: string) => { + try { + await api.delete(`/api/neutralization/attributes/single/${mappingId}`, { + headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined, + }); + await _loadSources(); + } catch (err) { + console.error('Failed to delete mapping:', err); + } + }; + + const _handleRetrigger = async (fileId: string) => { + try { + await api.post( + '/api/neutralization/retrigger', + { fileId }, + { headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined }, + ); + await _loadSources(); + } catch (err) { + console.error('Failed to retrigger neutralization:', err); + } + }; + + const _statusBadge = (status: string) => { + const colors: Record = { + completed: { bg: '#dcfce7', text: '#166534' }, + pending: { bg: '#fef3c7', text: '#92400e' }, + failed: { bg: '#fef2f2', text: '#991b1b' }, + not_required: { bg: '#f3f4f6', text: '#6b7280' }, + }; + const c = colors[status] || colors.not_required; + return ( + + {status} + + ); + }; + + if (loading) return
Lade Neutralisierungsdaten...
; + + const _hasAnyData = sources.length > 0 || snapshots.length > 0; + + return ( +
+

Neutralisierung

+

+ Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original ↔ Platzhalter). +

+ + {/* ── Snapshots: neutralisierter Text ──────────────────────── */} + {snapshots.length > 0 && ( +
+
+ Neutralisierter Text ({snapshots.length}) +
+ {snapshots.map((snap) => { + const _isExpanded = expandedSnapshot === snap.id; + return ( +
+
setExpandedSnapshot(_isExpanded ? null : snap.id)} + style={{ + padding: '8px 12px', + background: 'var(--bg-hover, #f9fafb)', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + fontSize: '0.85rem', + }} + > + {snap.sourceLabel} + + {snap.placeholderCount} Platzhalter {_isExpanded ? '\u25BC' : '\u25B6'} + +
+ {_isExpanded && ( +
+ {_renderHighlightedText(snap.neutralizedText, _mappingLookup)} +
+ )} +
+ ); + })} +
+ )} + + {/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */} + {sources.length > 0 && ( +
+
+ Datenquellen +
+ {sources.map((src) => ( +
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)} + > +
+
{src.fileName}
+
+ {_statusBadge(src.neutralizationStatus)} + {src.mappingCount > 0 && ( + {src.mappingCount} Mapping(s) + )} +
+
+
+ {!src.isVirtual && ( + + )} + {selectedSource === src.fileId ? '\u25BC' : '\u25B6'} +
+
+ ))} +
+ )} + + {/* ── Mappings für ausgewählte Quelle ──────────────────────── */} + {selectedSource && mappings.length > 0 && ( +
+
+ Platzhalter-Mappings ({mappings.length}) +
+
+ {mappings.map((m) => ( +
+ {m.placeholder} + {'\u2192'} + + {m.originalText} + + {m.patternType} + +
+ ))} +
+
+ )} + + {selectedSource && mappings.length === 0 && ( +
+ {selectedSource === _chatPromptSourceId + ? 'Keine Mappings ohne Dateizuordnung.' + : 'Keine gespeicherten Mappings für diese Datenquelle.'} +
+ )} + + {!_hasAnyData && ( +
+ Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung. +
+ )} +
+ ); +}; + +export default NeutralizationPanel; diff --git a/src/pages/views/workspace/WorkspaceEditorPage.tsx b/src/pages/views/workspace/WorkspaceEditorPage.tsx index 8cb7b5d..7629520 100644 --- a/src/pages/views/workspace/WorkspaceEditorPage.tsx +++ b/src/pages/views/workspace/WorkspaceEditorPage.tsx @@ -15,6 +15,7 @@ import type { editor as monacoEditor } from 'monaco-editor'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor'; import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa'; +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; function _getMonacoLanguage(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase() || ''; @@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string { return langMap[ext] || 'plaintext'; } -function _formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - export const WorkspaceEditorPage: React.FC = () => { const instanceId = useInstanceId() || ''; const navigate = useNavigate(); @@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => { }}>
{activeEdit.fileName} - Original: {_formatBytes(activeEdit.oldContent.length)} - Geaendert: {_formatBytes(activeEdit.newContent.length)} + Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)} + Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}
)} - {onProvidersChange && ( + {onProviderSelectionChange && providerSelection && ( )} @@ -665,7 +699,11 @@ export const WorkspaceInput: React.FC = ({ {_STT_LANGUAGES.map(lang => (
{ setVoiceLanguage(lang.code); setShowLangPicker(false); }} + onClick={() => { + setVoiceLanguage(lang.code); + setShowLangPicker(false); + fetch('/api/voice/preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {}); + }} style={{ padding: '8px 12px', cursor: 'pointer', fontSize: 13, background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent', @@ -681,6 +719,21 @@ export const WorkspaceInput: React.FC = ({ )}
+ + {isProcessing ? ( - - - + const _handleRenameChat = useCallback(async (chatId: string, newName: string) => { + try { + await api.patch(`/api/workspace/${instanceId}/workflows/${chatId}`, { name: newName }); + } catch (err) { + console.error('Failed to rename chat:', err); + } + }, [instanceId]); -
- {leftTab === 'conversations' && ( - - )} - {leftTab === 'files' && ( - - )} - {leftTab === 'datasources' && ( - - )} -
- + const _handleDeleteChat = useCallback(async (chatId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/workflows/${chatId}`); + if (workspace.workflowId === chatId) { + workspace.resetToNew(); + } + } catch (err) { + console.error('Failed to delete chat:', err); + } + }, [instanceId, workspace]); + + const _udbContext: UdbContext = { + instanceId: instanceId, + mandateId: mandateId, + featureInstanceId: instanceId, + }; + + const _leftPanelBody = ( + ); const _rightPanelBody = ( @@ -397,9 +416,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance /> { + onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => { const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])]; - workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds); + const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders); + workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options); setPendingFiles([]); }} isProcessing={workspace.isProcessing} @@ -411,11 +431,13 @@ export const WorkspacePage: React.FC = ({ persistentInstance onRemovePendingFile={_handleRemovePendingFile} onFileUploadClick={() => fileInputRef.current?.click()} uploading={fileOps.uploadingFile} - selectedProviders={selectedProviders} - onProvidersChange={setSelectedProviders} + providerSelection={providerSelection} + onProviderSelectionChange={setProviderSelection} isMobile={isMobile} onTreeItemsDrop={_handleTreeItemsDrop} onPasteAsFile={_uploadAndAttach} + draftAppend={draftAppend} + onDraftAppendConsumed={() => setDraftAppend('')} /> diff --git a/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx b/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx index 9256ade..4a6ade6 100644 --- a/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx +++ b/src/pages/views/workspace/WorkspaceRagInsightsPage.tsx @@ -21,6 +21,7 @@ import { import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useApiRequest } from '../../../hooks/useApi'; import styles from './WorkspaceRagInsightsPage.module.css'; +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; const MIME_LABELS: Record = { pdf: 'PDF', @@ -35,18 +36,6 @@ const MIME_LABELS: Record = { const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828']; -function _formatBytes(n: number): string { - if (!Number.isFinite(n) || n <= 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB']; - let v = n; - let i = 0; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i += 1; - } - return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; -} - interface RagKpis { indexedDocuments: number; indexedBytesTotal: number; @@ -161,7 +150,7 @@ export const WorkspaceRagInsightsPage: React.FC = () => {

Indexierte Dokumente

-

{_formatBytes(kpis.indexedBytesTotal)}

+

{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}

Indexiertes Datenvolumen (geschätzt)

diff --git a/src/pages/views/workspace/WorkspaceSettings.module.css b/src/pages/views/workspace/WorkspaceSettings.module.css deleted file mode 100644 index 8138b1e..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.module.css +++ /dev/null @@ -1,173 +0,0 @@ -.settings { - padding: 1rem; - max-width: 600px; -} - -.heading { - font-size: 1.2rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--text-primary, #333); -} - -.loading { - padding: 2rem; - text-align: center; - color: var(--text-secondary, #666); -} - -.error { - padding: 0.5rem 0.75rem; - background: #fde8e8; - color: var(--color-error, #d32f2f); - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.success { - padding: 0.5rem 0.75rem; - background: #e8f5e9; - color: #2e7d32; - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.section { - margin-bottom: 2rem; -} - -.sectionTitle { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--text-primary, #333); -} - -.field { - margin-bottom: 0.75rem; -} - -.label { - display: block; - font-size: 0.85rem; - font-weight: 500; - margin-bottom: 0.3rem; - color: var(--text-primary, #333); -} - -.select, .input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color, #ddd); - border-radius: 6px; - font-size: 0.9rem; - background: var(--bg-input, #fff); - color: var(--text-primary, #333); -} - -.voiceRow { - display: flex; - gap: 0.5rem; -} - -.voiceRow .select { - flex: 1; -} - -.testBtn, .addBtn, .removeBtn { - padding: 0.5rem 1rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; - white-space: nowrap; -} - -.testBtn:hover:not(:disabled), -.addBtn:hover:not(:disabled) { filter: brightness(1.08); } - -.testBtn:disabled, -.addBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.removeBtn { - background: transparent; - color: var(--color-error, #d32f2f); - padding: 0.3rem 0.6rem; - font-size: 0.8rem; - border: 1px solid var(--color-error, #d32f2f); -} - -.removeBtn:hover { background: #fde8e8; } - -.voiceTable { - width: 100%; - border-collapse: collapse; - margin-top: 0.75rem; -} - -.voiceTable th, -.voiceTable td { - text-align: left; - padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--border-color, #e0e0e0); - font-size: 0.85rem; -} - -.voiceTable th { - font-weight: 600; - color: var(--text-secondary, #666); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.emptyHint { - color: var(--text-secondary, #999); - font-size: 0.85rem; - font-style: italic; - padding: 0.5rem 0; -} - -.saveBtn { - width: 100%; - padding: 0.6rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.9rem; - font-weight: 500; -} - -.saveBtn:hover:not(:disabled) { filter: brightness(1.08); } -.saveBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.backBtn { - background: none; - border: none; - cursor: pointer; - font-size: 0.85rem; - color: var(--primary-color, #1976d2); - padding: 0; - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 4px; -} - -.backBtn:hover { text-decoration: underline; } diff --git a/src/pages/views/workspace/WorkspaceSettings.tsx b/src/pages/views/workspace/WorkspaceSettings.tsx deleted file mode 100644 index 11e9c46..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** - * WorkspaceSettings -- Voice preferences per language. - * - * Allows the user to configure a preferred voice for each TTS language. - * Language detection is automatic; this page lets users override the - * default Google Cloud voice for specific languages. - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import { useApiRequest } from '../../../hooks/useApi'; -import styles from './WorkspaceSettings.module.css'; - -interface VoiceMapEntry { - language: string; - voiceName: string; -} - -interface WorkspaceSettingsProps { - instanceId: string; -} - -export const WorkspaceSettings: React.FC = ({ instanceId }) => { - const { request } = useApiRequest(); - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(null); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - const [languages, setLanguages] = useState([]); - const [voiceMap, setVoiceMap] = useState([]); - - const [addLanguage, setAddLanguage] = useState('de-DE'); - const [addVoices, setAddVoices] = useState([]); - const [addVoiceName, setAddVoiceName] = useState(''); - const [loadingVoices, setLoadingVoices] = useState(false); - - const _loadSettings = useCallback(async () => { - if (!instanceId) return; - setLoading(true); - try { - const [settingsData, languagesData] = await Promise.all([ - request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }), - request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }), - ]); - - const langList = (languagesData as any)?.languages || []; - setLanguages(langList); - - const map: Record = (settingsData as any)?.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 Einstellungen'); - } finally { - setLoading(false); - } - }, [request, instanceId]); - - useEffect(() => { _loadSettings(); }, [_loadSettings]); - - const _loadVoicesForLanguage = useCallback(async (lang: string) => { - if (!instanceId) return; - setLoadingVoices(true); - try { - const result = await request({ - url: `/api/workspace/${instanceId}/voice/voices`, - method: 'get', - params: { language: lang }, - }); - setAddVoices((result as any)?.voices || []); - setAddVoiceName(''); - } catch { - setAddVoices([]); - } finally { - setLoadingVoices(false); - } - }, [request, instanceId]); - - 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 () => { - if (!instanceId) return; - setSaving(true); - setError(null); - setSuccess(null); - try { - const mapObj: Record = {}; - voiceMap.forEach(e => { - mapObj[e.language] = { voiceName: e.voiceName || '' }; - }); - const putResult = await request({ - url: `/api/workspace/${instanceId}/settings/voice`, - method: 'put', - data: { ttsVoiceMap: mapObj }, - }); - if ((putResult as any)?.error) { - setError((putResult as any).error); - return; - } - setSuccess('Einstellungen gespeichert'); - setTimeout(() => setSuccess(null), 3000); - await _loadSettings(); - } catch (err: any) { - setError(err.message || 'Fehler beim Speichern'); - } finally { - setSaving(false); - } - }, [request, instanceId, voiceMap]); - - const _handleTestVoice = useCallback(async (lang: string, voice: string) => { - if (!instanceId) return; - setTesting(lang); - try { - const result: any = await request({ - url: `/api/workspace/${instanceId}/voice/test`, - method: 'post', - data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, - }); - 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, instanceId]); - - const _getLanguageName = useCallback((code: string) => { - const found = languages.find((l: any) => (l.code || l) === code); - return found?.name || found?.code || code; - }, [languages]); - - if (loading) { - return
Einstellungen werden geladen...
; - } - - 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; - - return ( -
-

Stimmeneinstellungen

- - {error &&
{error}
} - {success &&
{success}
} - -
-

Konfigurierte Stimmen pro Sprache

-

- Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. -

- - {voiceMap.length === 0 ? ( -
- Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. -
- ) : ( - - - - - - - - - - - {voiceMap.map(entry => ( - - - - - - - ))} - -
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} - - - -
- )} -
- -
-

Stimme hinzufuegen / aendern

- -
- - -
- -
- -
- - -
-
- - -
- - -
- ); -}; - -export default WorkspaceSettings; diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx index d19e0df..644f253 100644 --- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx +++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx @@ -1,20 +1,20 @@ /** * WorkspaceSettingsPage -- Tabbed settings for the AI Workspace. * - * First tab: Voice / Language (WorkspaceSettings). - * Additional tabs can be added here as needed. + * Tabs: General settings, Neutralization. + * Voice settings are now in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState } from 'react'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { WorkspaceSettings } from './WorkspaceSettings'; import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings'; +import NeutralizationPanel from './NeutralizationPanel'; -type SettingsTab = 'general' | 'voice'; +type SettingsTab = 'general' | 'neutralization'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'general', label: 'Generelle Einstellungen' }, - { key: 'voice', label: 'Sprache & Stimme' }, + { key: 'neutralization', label: 'Neutralisierung (Workspace)' }, ]; export const WorkspaceSettingsPage: React.FC = () => { @@ -24,7 +24,7 @@ export const WorkspaceSettingsPage: React.FC = () => { if (!instanceId) { return (
- Keine Workspace-Instanz ausgewählt. + Keine Workspace-Instanz ausgewaehlt.
); } @@ -66,8 +66,15 @@ export const WorkspaceSettingsPage: React.FC = () => { {activeTab === 'general' && ( )} - {activeTab === 'voice' && ( - + {activeTab === 'neutralization' && ( + <> +

+ Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser + Workspace-Instanz. (Die Benutzer-Einstellungen unter /settings → „Neutralisierung (lokal)“ + ist eine andere Seite.) +

+ + )}
diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index 7da9299..c5dc589 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -40,6 +40,8 @@ export interface WorkspaceFile { description?: string; featureInstanceId?: string; featureInstanceLabel?: string; + scope: string; + neutralize: boolean; } export interface WorkspaceFolder { @@ -56,6 +58,8 @@ export interface DataSource { label: string; /** Human-readable full path (service + folders); used for tooltips */ displayPath?: string; + scope: string; + neutralize: boolean; } export interface FeatureDataSource { @@ -91,7 +95,7 @@ export interface DataSourceAccessEvent { interface UseWorkspaceReturn { messages: Message[]; isProcessing: boolean; - sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[]) => void; + sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void; stopProcessing: () => void; loadWorkflow: (workflowId: string) => void; resetToNew: () => void; @@ -193,7 +197,7 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { }, []); const sendMessage = useCallback( - (prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = []) => { + (prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = [], options?: { requireNeutralization?: boolean }) => { if (!instanceId || isProcessing) return; setIsProcessing(true); @@ -238,6 +242,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { if (allowedProviders.length > 0) { body.allowedProviders = allowedProviders; } + if (options?.requireNeutralization !== undefined) { + body.requireNeutralization = options.requireNeutralization; + } cleanupRef.current = startSseStream({ url, diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 0d695b7..5430a3a 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -322,14 +322,14 @@ export function hasAccess(level: AccessLevel): boolean { */ export function canAccessRecord( level: AccessLevel, - record: { _createdBy?: string }, + record: { sysCreatedBy?: string }, userId: string ): boolean { switch (level) { case 'n': return false; case 'm': - return record._createdBy === userId; + return record.sysCreatedBy === userId; case 'g': case 'a': return true; diff --git a/src/utils/formatDataSize.ts b/src/utils/formatDataSize.ts new file mode 100644 index 0000000..f0f6668 --- /dev/null +++ b/src/utils/formatDataSize.ts @@ -0,0 +1,43 @@ +/** + * Central binary (1024) data-size formatting for the UI. + * + * - Use formatBinaryDataSizeBytes for raw byte counts (files, RAG totals, …). + * - Use formatBinaryDataSizeFromMebibytes for API fields stored as MB (mebibytes), e.g. maxDataVolumeMB. + */ + +const BINARY_BASE = 1024; +const BINARY_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +function _maxFractionDigits(value: number): number { + if (value >= 100 || Number.isInteger(value)) return 0; + if (value >= 10) return 1; + return 2; +} + +/** + * Human-readable size from a byte count; picks B … TB automatically (1024-based). + */ +export function formatBinaryDataSizeBytes(bytes: number, localeId = 'de-CH'): string { + if (!Number.isFinite(bytes)) return '—'; + if (bytes < 0) return '—'; + if (bytes === 0) return `0 ${BINARY_UNITS[0]}`; + + const rawExp = Math.floor(Math.log(bytes) / Math.log(BINARY_BASE)); + const exp = Math.max(0, Math.min(BINARY_UNITS.length - 1, rawExp)); + const value = bytes / BINARY_BASE ** exp; + const maxFrac = _maxFractionDigits(value); + const formatted = new Intl.NumberFormat(localeId, { + maximumFractionDigits: maxFrac, + minimumFractionDigits: 0, + }).format(value); + return `${formatted} ${BINARY_UNITS[exp]}`; +} + +/** + * Same as formatBinaryDataSizeBytes, but input is mebibytes (API convention for plan limits). + */ +export function formatBinaryDataSizeFromMebibytes(mebibytes: number, localeId = 'de-CH'): string { + if (!Number.isFinite(mebibytes) || mebibytes < 0) return '—'; + if (mebibytes === 0) return `0 ${BINARY_UNITS[0]}`; + return formatBinaryDataSizeBytes(mebibytes * BINARY_BASE * BINARY_BASE, localeId); +} diff --git a/src/utils/mandateBillingFormMerge.ts b/src/utils/mandateBillingFormMerge.ts index ce06580..4cc916f 100644 --- a/src/utils/mandateBillingFormMerge.ts +++ b/src/utils/mandateBillingFormMerge.ts @@ -7,8 +7,6 @@ import type { AttributeDefinition } from '../components/FormGenerator/FormGenera import type { BillingSettings, BillingSettingsUpdate } from '../api/billingApi'; export const mandateBillingFieldNames = [ - 'billingModel', - 'defaultUserCredit', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails', @@ -19,31 +17,6 @@ export type MandateBillingFieldName = (typeof mandateBillingFieldNames)[number]; /** FormGenerator attribute definitions for mandate billing (appended after /api/attributes/Mandate). */ export function getMandateBillingFormAttributes(): AttributeDefinition[] { return [ - { - name: 'billingModel', - type: 'select', - label: 'Abrechnungsmodell', - description: 'Vorauszahlung auf Mandanten- oder Benutzerkonten.', - required: false, - default: 'PREPAY_MANDATE', - editable: true, - order: 100, - options: [ - { value: 'PREPAY_MANDATE', label: 'Vorauszahlung (Mandanten-Guthaben)' }, - { value: 'PREPAY_USER', label: 'Vorauszahlung pro Benutzer' }, - ], - }, - { - name: 'defaultUserCredit', - type: 'float', - label: 'Startguthaben neuem Benutzer (CHF)', - description: - 'Nur relevant bei PREPAY_USER (u. a. Root-Mandant). Sonst meist 0.', - required: false, - default: 0, - editable: true, - order: 101, - }, { name: 'warningThresholdPercent', type: 'float', @@ -61,7 +34,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] { required: false, default: true, editable: true, - order: 103, + order: 102, }, { name: 'notifyEmails', @@ -71,7 +44,7 @@ export function getMandateBillingFormAttributes(): AttributeDefinition[] { required: false, default: '', editable: true, - order: 104, + order: 103, minRows: 2, maxRows: 6, }, @@ -91,12 +64,6 @@ function _parseNotifyEmailsInput(val: unknown): string[] { .filter(Boolean); } -/** Build initial form values: mandate row + billing settings (notifyEmails as multi-line string). */ -function _normalizeBillingModelUi(raw: string | undefined): BillingSettings['billingModel'] { - if (raw === 'PREPAY_USER') return 'PREPAY_USER'; - return 'PREPAY_MANDATE'; -} - export function mergeBillingIntoMandateFormData( mandate: Record, settings: BillingSettings | null @@ -104,8 +71,6 @@ export function mergeBillingIntoMandateFormData( if (!settings) { return { ...mandate, - billingModel: 'PREPAY_MANDATE', - defaultUserCredit: 0, warningThresholdPercent: 10, notifyOnWarning: true, notifyEmails: '', @@ -113,8 +78,6 @@ export function mergeBillingIntoMandateFormData( } return { ...mandate, - billingModel: _normalizeBillingModelUi(settings.billingModel), - defaultUserCredit: Number(settings.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), notifyOnWarning: settings.notifyOnWarning ?? true, notifyEmails: (settings.notifyEmails || []).join('\n'), @@ -131,19 +94,6 @@ export function splitMandateAndBillingFromForm( if ('enabled' in formData) mandatePayload.enabled = formData.enabled; const billingUpdate: BillingSettingsUpdate = {}; - if ('billingModel' in formData && formData.billingModel !== undefined && formData.billingModel !== '') { - billingUpdate.billingModel = formData.billingModel as BillingSettingsUpdate['billingModel']; - } - { - const raw = formData.defaultUserCredit; - const n = - raw === undefined || raw === null || raw === '' - ? 0 - : Number(raw); - if (!Number.isNaN(n)) { - billingUpdate.defaultUserCredit = n; - } - } if ( 'warningThresholdPercent' in formData && formData.warningThresholdPercent !== undefined &&