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..df0ed6c 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; @@ -299,6 +285,13 @@ export async function cancelSessionApi(request: ApiRequestFunction, instanceId: // Streaming Chat API // ============================================================================ +export interface SendMessageOptions { + fileIds?: string[]; + dataSourceIds?: string[]; + featureDataSourceIds?: string[]; + allowedProviders?: string[]; +} + export async function sendMessageStreamApi( instanceId: string, sessionId: string, @@ -307,6 +300,7 @@ export async function sendMessageStreamApi( onError?: (error: Error) => void, onComplete?: () => void, signal?: AbortSignal, + options?: SendMessageOptions, ): Promise { try { const baseURL = api.defaults.baseURL || ''; @@ -318,10 +312,16 @@ export async function sendMessageStreamApi( if (!getCSRFToken()) generateAndStoreCSRFToken(); addCSRFTokenToHeaders(headers); + const body: Record = { content }; + if (options?.fileIds?.length) body.fileIds = options.fileIds; + if (options?.dataSourceIds?.length) body.dataSourceIds = options.dataSourceIds; + if (options?.featureDataSourceIds?.length) body.featureDataSourceIds = options.featureDataSourceIds; + if (options?.allowedProviders?.length) body.allowedProviders = options.allowedProviders; + const response = await fetch(url, { method: 'POST', headers, - body: JSON.stringify({ content }), + body: JSON.stringify(body), credentials: 'include', signal, }); @@ -494,27 +494,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 +514,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/mandateApi.ts b/src/api/mandateApi.ts index b29d138..9f4076d 100644 --- a/src/api/mandateApi.ts +++ b/src/api/mandateApi.ts @@ -122,7 +122,7 @@ export async function createMandate( } /** - * Delete a mandate + * Soft-delete a mandate (sets enabled=false, 30-day retention) * Endpoint: DELETE /api/mandates/{mandateId} */ export async function deleteMandate( @@ -134,3 +134,22 @@ export async function deleteMandate( method: 'delete' }); } + +/** + * Hard-delete a mandate with full cascade (irreversible) + * Endpoint: DELETE /api/mandates/{mandateId}?force=true + */ +export async function hardDeleteMandate( + request: ApiRequestFunction, + mandateId: string, + confirmName: string +): Promise { + await request({ + url: `/api/mandates/${mandateId}`, + method: 'delete', + params: { force: true }, + additionalConfig: { + headers: { 'X-Confirm-Name': confirmName } + } + }); +} 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/Automation2FlowEditor/nodes/shared/clickupFormSync.ts b/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts index f249656..5aab3d3 100644 --- a/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts +++ b/src/components/Automation2FlowEditor/nodes/shared/clickupFormSync.ts @@ -225,9 +225,20 @@ export function buildSyncFromClickUpList(args: { { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false }, ]; + const statusTriggerRow: TriggerFormFieldRow | null = + statusOpts.length > 0 + ? { + name: PAYLOAD_STATUS, + label: 'Status', + type: 'clickup_status', + statusOptions: statusOpts, + } + : null; + const standardTrigger: TriggerFormFieldRow[] = [ { name: PAYLOAD_TITLE, label: 'Titel', type: 'text' }, { name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' }, + ...(statusTriggerRow ? [statusTriggerRow] : []), { name: PAYLOAD_PRIORITY, label: 'Priorität (1–4)', type: 'number' }, { name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' }, { name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' }, @@ -252,8 +263,9 @@ export function buildSyncFromClickUpList(args: { if (inf) customInput.push(inf); if (tr) customTrigger.push(tr); const fid = String((f as ClickUpFieldLike).id ?? ''); - if (fid && inf?.name) { - customRefs[fid] = createRef(formNodeId, ['payload', inf.name]); + const payloadKey = inf?.name; + if (fid && payloadKey) { + customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]); } } 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..1530585 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -25,7 +25,7 @@ .treeNode.multiSelected { background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14)); - box-shadow: inset 3px 0 0 var(--color-primary, #1976d2); + box-shadow: inset 3px 0 0 var(--color-primary, #F25843); } .treeNode.multiSelected:hover { @@ -34,7 +34,7 @@ .treeNode.dropTarget { background: var(--color-bg-drop, rgba(25, 118, 210, 0.15)); - outline: 2px dashed var(--color-primary, #1976d2); + outline: 2px dashed var(--color-primary, #F25843); outline-offset: -2px; } @@ -77,7 +77,7 @@ .renameInput { flex: 1; - border: 1px solid var(--color-primary, #1976d2); + border: 1px solid var(--color-primary, #F25843); border-radius: 3px; padding: 1px 4px; font-size: inherit; @@ -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..3712664 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, type PromptOptions } 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, options?: PromptOptions) => 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/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 351a59b..f53f0a2 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -226,6 +226,7 @@ export interface FormGeneratorTableProps { groupRowData?: (groupKey: string, groupRows: T[]) => Record; groupDefaultExpanded?: boolean; groupActions?: (groupKey: string, groupRows: T[]) => React.ReactNode; + initialSearchTerm?: string; rowDraggable?: boolean; onRowDragStart?: (e: React.DragEvent, row: T) => void; } @@ -327,6 +328,7 @@ export function FormGeneratorTable>({ groupRowData, groupDefaultExpanded = true, groupActions, + initialSearchTerm = '', rowDraggable = false, onRowDragStart, }: FormGeneratorTableProps) { @@ -368,7 +370,7 @@ export function FormGeneratorTable>({ }, [providedColumns, data]); // State management - const [searchTerm, setSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [searchFocused, setSearchFocused] = useState(false); const [filterFocused, setFilterFocused] = useState>({}); // Multi-column sorting: array of sort configs in order of priority 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..93cbc21 --- /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/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx new file mode 100644 index 0000000..b1d1828 --- /dev/null +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -0,0 +1,1608 @@ +/** + * SourcesTab – Full data-source management inside the Unified Data Bar. + * + * 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) + * + * 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 type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import { getPageIcon } from '../../config/pageRegistry'; +import styles from './SourcesTab.module.css'; + +/* ─── 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; + recordFilter?: Record; +} + +interface TreeNode { + key: string; + label: string; + icon: string; + type: 'connection' | 'service' | 'folder' | 'file'; + expanded: boolean; + loading: boolean; + children: TreeNode[] | null; + connectionId: string; + service?: string; + path?: string; + displayPath?: string; + authority?: string; +} + +interface FeatureConnectionNode { + featureInstanceId: string; + featureCode: string; + mandateId?: string; + label: string; + icon: string; + tableCount: number; + expanded: boolean; + loading: boolean; + tables: FeatureTableNode[] | null; + parentRecords: Record; +} + +interface MandateGroupNode { + mandateId: string; + mandateLabel: string; + expanded: boolean; + featureConnections: FeatureConnectionNode[]; +} + +interface FeatureTableNode { + objectKey: string; + tableName: string; + label: Record; + fields: string[]; + isParent?: boolean; + parentTable?: string; + parentKey?: string; + displayFields?: string[]; +} + +interface ParentRecordNode { + id: string; + displayLabel: string; + fields: Record; + tableName: string; + expanded: boolean; +} + +/* ─── Props ──────────────────────────────────────────────────────────── */ + +interface SourcesTabProps { + context: UdbContext; + onSourcesChanged?: () => void; +} + +/* ─── Icons ──────────────────────────────────────────────────────────── */ + +const _AUTHORITY_ICONS: Record = { + msft: '\uD83D\uDFE6', + google: '\uD83D\uDFE9', + clickup: '\uD83D\uDCCB', + 'local:ftp': '\uD83D\uDD17', + 'local:jira': '\uD83D\uDD27', +}; + +const _SERVICE_ICONS: Record = { + sharepoint: '\uD83D\uDCC1', + onedrive: '\u2601\uFE0F', + outlook: '\uD83D\uDCE7', + teams: '\uD83D\uDCAC', + drive: '\uD83D\uDCC2', + gmail: '\uD83D\uDCE8', + files: '\uD83D\uDCC2', +}; + +/* ─── Source colors & icons ──────────────────────────────────────────── */ + +const _SOURCE_COLORS: Record = { + sharepointFolder: '#0078d4', + sharepoint: '#0078d4', + onedriveFolder: '#0078d4', + onedrive: '#0078d4', + outlookFolder: '#0078d4', + outlook: '#0078d4', + googleDriveFolder: '#34a853', + drive: '#34a853', + gmailFolder: '#ea4335', + gmail: '#ea4335', + ftpFolder: '#795548', + files: '#795548', + 'local:ftp': '#795548', + 'local:jira': '#0052CC', + clickup: '#7b68ee', +}; + +function _getSourceColor(sourceType: string): string { + return _SOURCE_COLORS[sourceType] || '#F25843'; +} + +const _SOURCE_ICONS: Record = { + sharepointFolder: '\uD83D\uDCC1', + sharepoint: '\uD83D\uDCC1', + onedriveFolder: '\u2601\uFE0F', + onedrive: '\u2601\uFE0F', + outlookFolder: '\uD83D\uDCE7', + outlook: '\uD83D\uDCE7', + googleDriveFolder: '\uD83D\uDCC2', + drive: '\uD83D\uDCC2', + gmailFolder: '\uD83D\uDCE8', + gmail: '\uD83D\uDCE8', + ftpFolder: '\uD83D\uDD17', + files: '\uD83D\uDD17', + 'local:ftp': '\uD83D\uDD17', + 'local:jira': '\uD83D\uDD27', + clickup: '\uD83D\uDCCB', +}; + +function _getSourceIcon(sourceType: string): string { + return _SOURCE_ICONS[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]; +} + +const _SERVICE_TO_SOURCE_TYPE: Record = { + sharepoint: 'sharepointFolder', + onedrive: 'onedriveFolder', + outlook: 'outlookFolder', + drive: 'googleDriveFolder', + gmail: 'gmailFolder', + files: 'ftpFolder', +}; + +/* ─── 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, + updater: (n: FeatureConnectionNode) => FeatureConnectionNode, +): MandateGroupNode[] { + return prev.map(g => ({ + ...g, + featureConnections: g.featureConnections.map(n => + n.featureInstanceId === featureInstanceId ? updater(n) : n + ), + })); +} + +function _findFeatureInstanceMeta( + groups: MandateGroupNode[], + featureInstanceId: string, +): { mandateLabel: string; instanceLabel: string } | null { + for (const g of groups) { + const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); + if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label }; + } + return null; +} + +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: UdbFeatureDataSource, +): string { + const parts: string[] = []; + if (meta) { + parts.push(meta.mandateLabel, meta.instanceLabel); + } + const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName + ? `${fds.label} (${fds.tableName})` + : (fds.label || fds.tableName); + parts.push(labelPart); + if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) { + parts.push(fds.objectKey); + } + return parts.join(' / '); +} + +/* ─── Data fetching (module-level) ───────────────────────────────────── */ + +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, onSourcesChanged }) => { + 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, + recordFilter: d.recordFilter || undefined, + })); + setFeatureDataSources(list); + }) + .catch(() => { if (mountedRef.current) setFeatureDataSources([]); }); + }, [instanceId]); + + useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]); + useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]); + + /* ── Load Level 1: UserConnections ── */ + const _loadConnections = useCallback(() => { + if (!instanceId) return; + setLoadingRoot(true); + api.get(`/api/workspace/${instanceId}/connections`) + .then(res => { + if (!mountedRef.current) return; + const conns = res.data.connections || []; + const nodes: TreeNode[] = conns + .filter((c: any) => c.status === 'active') + .map((c: any) => ({ + key: `conn-${c.id}`, + label: c.externalEmail || c.externalUsername || c.authority, + icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', + type: 'connection' as const, + expanded: false, + loading: false, + children: null, + connectionId: c.id, + authority: c.authority, + })); + setTree(nodes); + }) + .catch(() => { if (mountedRef.current) setTree([]); }) + .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); + }, [instanceId]); + + useEffect(() => { _loadConnections(); }, [_loadConnections]); + + /* ── Generic tree update helper ── */ + const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { + setTree(prev => _mapTree(prev, key, updater)); + }, []); + + /* ── Toggle expand/collapse ── */ + const _toggleNode = useCallback(async (node: TreeNode) => { + if (node.expanded) { + _updateNode(node.key, n => ({ ...n, expanded: false })); + return; + } + + if (node.children !== null) { + _updateNode(node.key, n => ({ ...n, expanded: true })); + return; + } + + _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); + + try { + let children: TreeNode[] = []; + + if (node.type === 'connection') { + children = await _loadServices(instanceId, node.connectionId); + } else if (node.type === 'service' || node.type === 'folder') { + children = await _browseService( + instanceId, + node.connectionId, + node.service!, + node.path || '/', + node.displayPath || node.label, + ); + } + + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children })); + } + } catch { + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); + } + } + }, [instanceId, _updateNode]); + + /* ── Add as DataSource ── */ + const _addAsDataSource = useCallback(async (node: TreeNode) => { + if (!node.service || !node.connectionId) return; + setAddingPath(node.key); + try { + await api.post(`/api/workspace/${instanceId}/datasources`, { + connectionId: node.connectionId, + sourceType: _SERVICE_TO_SOURCE_TYPE[node.service] || node.service, + path: node.path || '/', + label: node.label, + displayPath: node.displayPath || node.label, + }); + _fetchDataSources(); + onSourcesChanged?.(); + } catch (err) { + console.error('Failed to add data source:', err); + } finally { + if (mountedRef.current) setAddingPath(null); + } + }, [instanceId, _fetchDataSources]); + + /* ── Remove DataSource ── */ + const _removeDatasource = useCallback(async (dsId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); + _fetchDataSources(); + onSourcesChanged?.(); + } catch (err) { + console.error('Failed to remove data source:', err); + } + }, [instanceId, _fetchDataSources]); + + /* ── Check if a path is already added ── */ + const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => { + const expectedSourceType = service ? (_SERVICE_TO_SOURCE_TYPE[service] || service) : undefined; + return dataSources.some(ds => + ds.connectionId === connectionId && + ds.path === (path || '/') && + (!expectedSourceType || ds.sourceType === expectedSourceType), + ); + }, [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; + setLoadingFeatures(true); + api.get(`/api/workspace/${instanceId}/feature-connections`) + .then(res => { + if (!mountedRef.current) return; + const groups = res.data.featureConnectionsByMandate || []; + setFeatureTree(groups.map((g: any) => ({ + mandateId: g.mandateId, + mandateLabel: g.mandateLabel || g.mandateId, + expanded: true, + featureConnections: (g.featureConnections || []).map((c: any) => ({ + featureInstanceId: c.featureInstanceId, + featureCode: c.featureCode, + mandateId: c.mandateId, + label: c.label, + icon: c.icon || '\uD83D\uDDC3\uFE0F', + tableCount: c.tableCount || 0, + expanded: false, + loading: false, + tables: null, + parentRecords: {}, + })), + }))); + }) + .catch(() => { if (mountedRef.current) setFeatureTree([]); }) + .finally(() => { if (mountedRef.current) setLoadingFeatures(false); }); + }, [instanceId]); + + useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]); + + /* ── Feature Connections: Toggle mandate group ── */ + const _toggleMandateGroup = useCallback((mandateId: string) => { + setFeatureTree(prev => prev.map(g => + g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g + )); + }, []); + + /* ── Feature Connections: Toggle expand ── */ + const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => { + if (node.expanded) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); + return; + } + + if (node.tables !== null) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); + return; + } + + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: true, expanded: true, + }))); + + try { + const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`); + const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({ + objectKey: t.objectKey, + tableName: t.tableName, + label: t.label || {}, + fields: t.fields || [], + isParent: t.isParent || false, + parentTable: t.parentTable || undefined, + parentKey: t.parentKey || undefined, + displayFields: t.displayFields || undefined, + })); + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables, + }))); + } + } catch { + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables: [], + }))); + } + } + }, [instanceId]); + + /* ── Feature: Add table as FeatureDataSource ── */ + const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => { + const key = `${node.featureInstanceId}-${table.tableName}`; + setAddingFeatureKey(key); + try { + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: node.featureInstanceId, + featureCode: node.featureCode, + tableName: table.tableName, + objectKey: table.objectKey, + label: table.label?.en || table.label?.de || table.tableName, + }); + _fetchFeatureDataSources(); + onSourcesChanged?.(); + } catch (err) { + console.error('Failed to add feature data source:', err); + } finally { + if (mountedRef.current) setAddingFeatureKey(null); + } + }, [instanceId, _fetchFeatureDataSources]); + + /* ── Feature: Remove FeatureDataSource ── */ + const _removeFeatureDataSource = useCallback(async (fdsId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); + _fetchFeatureDataSources(); + onSourcesChanged?.(); + } catch (err) { + console.error('Failed to remove feature data source:', err); + } + }, [instanceId, _fetchFeatureDataSources]); + + /* ── Feature: check if table already added ── */ + const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { + return featureDataSources.some(fds => + fds.featureInstanceId === featureInstanceId && fds.tableName === tableName, + ); + }, [featureDataSources]); + + /* ── Parent groups: expand/collapse + load records ── */ + const [expandedParentGroups, setExpandedParentGroups] = useState>(new Set()); + const [loadingParentGroup, setLoadingParentGroup] = useState(null); + const [addingParentKey, setAddingParentKey] = useState(null); + + const _toggleParentGroup = useCallback(async (node: FeatureConnectionNode, parentTableName: string) => { + const groupKey = `${node.featureInstanceId}-${parentTableName}`; + + if (expandedParentGroups.has(groupKey)) { + setExpandedParentGroups(prev => { const next = new Set(prev); next.delete(groupKey); return next; }); + return; + } + + setExpandedParentGroups(prev => new Set(prev).add(groupKey)); + + if (node.parentRecords[parentTableName]) return; + + setLoadingParentGroup(groupKey); + try { + const res = await api.get( + `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${parentTableName}`, + ); + const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({ + id: r.id, + displayLabel: r.displayLabel || r.id, + fields: r.fields || {}, + tableName: parentTableName, + expanded: false, + })); + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, + parentRecords: { ...n.parentRecords, [parentTableName]: records }, + }))); + } + } catch { + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, + parentRecords: { ...n.parentRecords, [parentTableName]: [] }, + }))); + } + } finally { + if (mountedRef.current) setLoadingParentGroup(null); + } + }, [instanceId, expandedParentGroups]); + + const _toggleParentRecord = useCallback((featureInstanceId: string, parentTableName: string, recordId: string) => { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, featureInstanceId, n => ({ + ...n, + parentRecords: { + ...n.parentRecords, + [parentTableName]: (n.parentRecords[parentTableName] || []).map(r => + r.id === recordId ? { ...r, expanded: !r.expanded } : r, + ), + }, + }))); + }, []); + + /* ── Parent record: add parent + all children with recordFilter ── */ + const _addParentRecord = useCallback(async ( + node: FeatureConnectionNode, + parentRecord: ParentRecordNode, + allTables: FeatureTableNode[], + ) => { + const addKey = `${node.featureInstanceId}-parent-${parentRecord.id}`; + setAddingParentKey(addKey); + try { + const parentTable = allTables.find(t => t.tableName === parentRecord.tableName && t.isParent); + const childTables = allTables.filter(t => t.parentTable === parentRecord.tableName); + + if (parentTable) { + const parentLabel = `${parentTable.label?.en || parentTable.label?.de || parentTable.tableName}: ${parentRecord.displayLabel}`; + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: node.featureInstanceId, + featureCode: node.featureCode, + tableName: parentTable.tableName, + objectKey: parentTable.objectKey, + label: parentLabel, + recordFilter: { id: parentRecord.id }, + }); + } + + for (const child of childTables) { + const childLabel = `${child.label?.en || child.label?.de || child.tableName}: ${parentRecord.displayLabel}`; + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: node.featureInstanceId, + featureCode: node.featureCode, + tableName: child.tableName, + objectKey: child.objectKey, + label: childLabel, + recordFilter: { [child.parentKey!]: parentRecord.id }, + }); + } + + _fetchFeatureDataSources(); + onSourcesChanged?.(); + } catch (err) { + console.error('Failed to add parent record sources:', err); + } finally { + if (mountedRef.current) setAddingParentKey(null); + } + }, [instanceId, _fetchFeatureDataSources]); + + /* ── Check if a parent record is already added ── */ + const _isParentRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => { + return featureDataSources.some(fds => + fds.featureInstanceId === featureInstanceId && + fds.tableName === parentTableName && + fds.recordFilter?.id === recordId, + ); + }, [featureDataSources]); + + /* ── Render ── */ + + return ( +
+ {/* ── Active Personal Sources ── */} + {dataSources.length > 0 && ( +
+
+ Active Personal Sources +
+ {[...dataSources].sort((a, b) => { + const aKey = `${a.sourceType}|${a.label || a.path || ''}`; + const bKey = `${b.sourceType}|${b.label || b.path || ''}`; + return aKey.localeCompare(bKey); + }).map(ds => { + const connColor = _getSourceColor(ds.sourceType); + const connNode = tree.find(n => n.connectionId === ds.connectionId); + const connLabel = connNode?.label || ds.connectionId; + const folder = ds.label || ds.path || ds.id; + return ( +
+ {_getSourceIcon(ds.sourceType)} + + {connLabel} – {folder} + + + + +
+ ); + })} +
+
+ )} + + {/* ── Browse Sources header ── */} +
+ + Browse Sources + + +
+ + {/* ── Browse Sources tree ── */} + {loadingRoot && tree.length === 0 && ( +
+ Loading connections... +
+ )} + + {!loadingRoot && tree.length === 0 && ( +
+ No active connections found. +
+ )} + + {tree.map(node => ( + <_TreeNodeView + key={node.key} + node={node} + depth={0} + onToggle={_toggleNode} + onAdd={_addAsDataSource} + isAdded={_isAdded} + addingPath={addingPath} + /> + ))} + + {/* ── Divider ── */} +
+ + {/* ── Active Feature Sources (grouped by parent record) ── */} + {featureDataSources.length > 0 && ( +
+
+ Active Feature Sources +
+ {(() => { + const sorted = [...featureDataSources].sort((a, b) => (a.label || a.tableName || '').localeCompare(b.label || b.tableName || '')); + const grouped: { key: string; label: string; items: UdbFeatureDataSource[] }[] = []; + const standalone: UdbFeatureDataSource[] = []; + + for (const fds of sorted) { + if (fds.recordFilter && Object.keys(fds.recordFilter).length > 0) { + const filterKey = `${fds.featureInstanceId}|${JSON.stringify(fds.recordFilter)}`; + let group = grouped.find(g => g.key === filterKey); + if (!group) { + const parentLabel = fds.label.includes(':') ? fds.label.split(':')[1]?.trim() : fds.label; + const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); + group = { key: filterKey, label: `${meta?.instanceLabel || fds.featureCode} – ${parentLabel}`, items: [] }; + grouped.push(group); + } + group.items.push(fds); + } else { + standalone.push(fds); + } + } + + return ( + <> + {grouped.map(group => ( +
+
+ {'\uD83D\uDCCB'} + + {group.label} + + +
+ {group.items.map(fds => { + const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); + return ( +
+ + {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDCC4'} + + + {fds.tableName} + + + + +
+ ); + })} +
+ ))} + {standalone.map(fds => { + const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); + const fdsConnLabel = meta?.instanceLabel || fds.tableName; + return ( +
+ + {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} + + + {fdsConnLabel} – {fds.tableName} + + + + +
+ ); + })} + + ); + })()} +
+
+ )} + + {/* ── Feature Data header ── */} +
+ + Feature Data + + +
+ + {/* ── Feature Data tree ── */} + {loadingFeatures && featureTree.length === 0 && ( +
+ Loading feature instances... +
+ )} + + {!loadingFeatures && featureTree.length === 0 && ( +
+ No feature instances found. +
+ )} + + {featureTree.map(g => ( + <_MandateGroupView + key={g.mandateId} + group={g} + onToggleGroup={_toggleMandateGroup} + onToggleFeature={_toggleFeatureNode} + onAddTable={_addFeatureTable} + isTableAdded={_isFeatureTableAdded} + addingKey={addingFeatureKey} + onToggleParentGroup={_toggleParentGroup} + onToggleParentRecord={_toggleParentRecord} + onAddParentRecord={_addParentRecord} + isParentRecordAdded={_isParentRecordAdded} + expandedParentGroups={expandedParentGroups} + loadingParentGroup={loadingParentGroup} + addingParentKey={addingParentKey} + /> + ))} +
+ ); +}; + +/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */ + +interface _TreeNodeViewProps { + node: TreeNode; + depth: number; + onToggle: (node: TreeNode) => void; + onAdd: (node: TreeNode) => void; + isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; + addingPath: string | null; +} + +const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ + node, depth, onToggle, onAdd, isAdded, addingPath, +}) => { + const [hovered, setHovered] = useState(false); + const hasChildren = node.type !== 'file'; + const chevron = hasChildren + ? (node.expanded ? '\u25BE' : '\u25B8') + : '\u00A0\u00A0'; + const canAdd = node.type === 'folder' || node.type === 'service'; + const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); + const isAdding = addingPath === node.key; + + return ( +
+
{ if (hasChildren) onToggle(node); }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 4, + paddingLeft: depth * 16 + 4, + paddingRight: 4, + paddingTop: 3, + paddingBottom: 3, + cursor: hasChildren ? 'pointer' : 'default', + borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', + userSelect: 'none', + }} + > + + {node.loading ? _Spinner() : chevron} + + {node.icon} + + {node.label} + + {canAdd && hovered && !alreadyAdded && ( + + )} + {canAdd && alreadyAdded && ( + + {'\u2713'} + + )} +
+ + {node.expanded && node.children && node.children.length > 0 && ( +
+ {node.children.map(child => ( + <_TreeNodeView + key={child.key} + node={child} + depth={depth + 1} + onToggle={onToggle} + onAdd={onAdd} + isAdded={isAdded} + addingPath={addingPath} + /> + ))} +
+ )} + + {node.expanded && node.children && node.children.length === 0 && !node.loading && ( +
+ (empty) +
+ )} +
+ ); +}; + +/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ + +interface _MandateGroupViewProps { + group: MandateGroupNode; + onToggleGroup: (mandateId: string) => void; + onToggleFeature: (node: FeatureConnectionNode) => void; + onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isTableAdded: (featureInstanceId: string, tableName: string) => boolean; + addingKey: string | null; + onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; + onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void; + onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void; + isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; + expandedParentGroups: Set; + loadingParentGroup: string | null; + addingParentKey: string | null; +} + +const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ + group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, + onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, + expandedParentGroups, loadingParentGroup, addingParentKey, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = group.expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
onToggleGroup(group.mandateId)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {chevron} + + + {group.mandateLabel} + +
+ + {group.expanded && ( +
+ {group.featureConnections.map(fNode => ( + <_FeatureNodeView + key={fNode.featureInstanceId} + node={fNode} + onToggle={onToggleFeature} + onAddTable={onAddTable} + isTableAdded={isTableAdded} + addingKey={addingKey} + onToggleParentGroup={onToggleParentGroup} + onToggleParentRecord={onToggleParentRecord} + onAddParentRecord={onAddParentRecord} + isParentRecordAdded={isParentRecordAdded} + expandedParentGroups={expandedParentGroups} + loadingParentGroup={loadingParentGroup} + addingParentKey={addingParentKey} + /> + ))} +
+ )} +
+ ); +}; + +/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */ + +interface _FeatureNodeViewProps { + node: FeatureConnectionNode; + onToggle: (node: FeatureConnectionNode) => void; + onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isTableAdded: (featureInstanceId: string, tableName: string) => boolean; + addingKey: string | null; + onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; + onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void; + onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void; + isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; + expandedParentGroups: Set; + loadingParentGroup: string | null; + addingParentKey: string | null; +} + +const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ + node, onToggle, onAddTable, isTableAdded, addingKey, + onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, + expandedParentGroups, loadingParentGroup, addingParentKey, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = node.expanded ? '\u25BE' : '\u25B8'; + + const parentTables = (node.tables || []).filter(t => t.isParent); + const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable); + + return ( +
+
onToggle(node)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {node.loading ? _Spinner() : chevron} + + + {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} + + + {node.label} + + + {node.tableCount} tables + +
+ + {node.expanded && node.tables && node.tables.length > 0 && ( +
+ {/* Parent table groups (hierarchical) */} + {parentTables.map(pt => { + const groupKey = `${node.featureInstanceId}-${pt.tableName}`; + const isGroupExpanded = expandedParentGroups.has(groupKey); + const isGroupLoading = loadingParentGroup === groupKey; + const records = node.parentRecords[pt.tableName]; + const childTables = (node.tables || []).filter(t => t.parentTable === pt.tableName); + const ptLabel = pt.label?.en || pt.label?.de || pt.tableName; + + return ( + <_ParentGroupView + key={groupKey} + featureNode={node} + parentTable={pt} + label={ptLabel} + expanded={isGroupExpanded} + loading={isGroupLoading} + records={records || null} + childTables={childTables} + allTables={node.tables!} + onToggleGroup={() => onToggleParentGroup(node, pt.tableName)} + onToggleRecord={(recordId) => onToggleParentRecord(node.featureInstanceId, pt.tableName, recordId)} + onAddRecord={(record) => onAddParentRecord(node, record, node.tables!)} + isRecordAdded={(recordId) => isParentRecordAdded(node.featureInstanceId, pt.tableName, recordId)} + addingParentKey={addingParentKey} + /> + ); + })} + + {/* Standalone tables (not part of any hierarchy) */} + {standaloneTables.map(table => ( + <_FeatureTableRow + key={table.objectKey} + featureNode={node} + table={table} + onAdd={onAddTable} + isAdded={isTableAdded(node.featureInstanceId, table.tableName)} + isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} + /> + ))} +
+ )} + + {node.expanded && node.tables && node.tables.length === 0 && !node.loading && ( +
+ (no tables) +
+ )} +
+ ); +}; + +/* ─── FeatureTableRow ────────────────────────────────────────────────── */ + +interface _FeatureTableRowProps { + featureNode: FeatureConnectionNode; + table: FeatureTableNode; + onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isAdded: boolean; + isAdding: boolean; +} + +const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ + featureNode, table, onAdd, isAdded, isAdding, +}) => { + const [hovered, setHovered] = useState(false); + const tableLabel = table.label?.en || table.label?.de || table.tableName; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + title={`${table.tableName}: ${table.fields.join(', ')}`} + > + {'\uD83D\uDCC1'} + + {tableLabel} + + {hovered && !isAdded && ( + + )} + {isAdded && ( + + {'\u2713'} + + )} +
+ ); +}; + +/* ─── ParentGroupView (parent table → parent records) ────────────────── */ + +interface _ParentGroupViewProps { + featureNode: FeatureConnectionNode; + parentTable: FeatureTableNode; + label: string; + expanded: boolean; + loading: boolean; + records: ParentRecordNode[] | null; + childTables: FeatureTableNode[]; + allTables: FeatureTableNode[]; + onToggleGroup: () => void; + onToggleRecord: (recordId: string) => void; + onAddRecord: (record: ParentRecordNode) => void; + isRecordAdded: (recordId: string) => boolean; + addingParentKey: string | null; +} + +const _ParentGroupView: React.FC<_ParentGroupViewProps> = ({ + featureNode, parentTable: _parentTable, label, expanded, loading, records, childTables, allTables, + onToggleGroup, onToggleRecord, onAddRecord, isRecordAdded, addingParentKey, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 24, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {loading ? _Spinner() : chevron} + + {'\uD83D\uDCC2'} + + {label} + + {childTables.length > 0 && ( + + +{childTables.length} tables + + )} +
+ + {expanded && records && records.length > 0 && ( +
+ {records.map(record => ( + <_ParentRecordRow + key={record.id} + featureNode={featureNode} + record={record} + childTables={childTables} + allTables={allTables} + onToggle={() => onToggleRecord(record.id)} + onAdd={() => onAddRecord(record)} + isAdded={isRecordAdded(record.id)} + isAdding={addingParentKey === `${featureNode.featureInstanceId}-parent-${record.id}`} + /> + ))} +
+ )} + + {expanded && records && records.length === 0 && !loading && ( +
+ (no records) +
+ )} +
+ ); +}; + +/* ─── ParentRecordRow (single parent record + child tables info) ─────── */ + +interface _ParentRecordRowProps { + featureNode: FeatureConnectionNode; + record: ParentRecordNode; + childTables: FeatureTableNode[]; + allTables: FeatureTableNode[]; + onToggle: () => void; + onAdd: () => void; + isAdded: boolean; + isAdding: boolean; +} + +const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ + featureNode: _featureNode, record, childTables, allTables: _allTables, + onToggle, onAdd, isAdded, isAdding, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = record.expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 44, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')} + > + + {chevron} + + {'\uD83D\uDCCB'} + + {record.displayLabel} + + {hovered && !isAdded && ( + + )} + {isAdded && ( + + {'\u2713'} + + )} +
+ + {record.expanded && ( +
+ {childTables.map(ct => { + const ctLabel = ct.label?.en || ct.label?.de || ct.tableName; + return ( +
+ {'\uD83D\uDCC4'} + {ctLabel} + ({ct.parentKey}) +
+ ); + })} +
+ )} +
+ ); +}; + +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..75bf641 --- /dev/null +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import ChatsTab from './ChatsTab'; +import FilesTab from './FilesTab'; +import SourcesTab from './SourcesTab'; +import styles from './UnifiedDataBar.module.css'; + +export type UdbTab = 'chats' | 'files' | 'sources'; + +export interface UdbContext { + instanceId: string; + mandateId?: string; + featureInstanceId?: string; + userId?: string; +} + +interface UnifiedDataBarProps { + context: UdbContext; + activeTab?: UdbTab; + onTabChange?: (tab: UdbTab) => void; + hideTabs?: UdbTab[]; + onSelectChat?: (chatId: string, featureInstanceId: string) => void; + activeWorkflowId?: string; + onCreateNewChat?: () => void; + onRenameChat?: (chatId: string, newName: string) => void; + onDeleteChat?: (chatId: string) => void; + onChatDragStart?: (chatId: string, event: React.DragEvent) => void; + onFileSelect?: (fileId: string) => void; + onSourcesChanged?: () => void; + className?: string; +} + +const _TAB_LABELS: Record> = { + 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, + onSourcesChanged, + 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/useCommcoach.ts b/src/hooks/useCommcoach.ts index 1e14ed1..62ec296 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -14,6 +14,7 @@ import { createTaskApi, updateTaskStatusApi, deleteTaskApi, type CoachingContext, type CoachingSession, type CoachingMessage, type CoachingTask, type CoachingScore, type SSEEvent, + type SendMessageOptions, } from '../api/commcoachApi'; import { useTtsPlayback, type TtsEvent } from './useTtsPlayback'; @@ -37,12 +38,14 @@ export interface CommcoachHookReturn { inputValue: string; setInputValue: (v: string) => void; + agentToolCalls: Array<{ toolName: string; args?: Record; result?: string; success?: boolean }>; + selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise; createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise; archiveContext: (contextId: string) => Promise; startSession: (personaId?: string) => Promise; - sendMessage: (content: string) => Promise; + sendMessage: (content: string, options?: SendMessageOptions) => Promise; sendAudio: (audioBlob: Blob) => Promise; completeSession: () => Promise; cancelSession: () => Promise; @@ -67,9 +70,10 @@ export interface CommcoachHookReturn { refreshContexts: () => Promise; } -export function useCommcoach(): CommcoachHookReturn { +export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn { const { request } = useApiRequest(); - const instanceId = useInstanceId(); + const routeInstanceId = useInstanceId(); + const instanceId = instanceIdOverride || routeInstanceId; const [contexts, setContexts] = useState([]); const [selectedContextId, setSelectedContextId] = useState(null); @@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn { const [error, setError] = useState(null); const [inputValue, setInputValue] = useState(''); + const [agentToolCalls, setAgentToolCalls] = useState; result?: string; success?: boolean }>>([]); const [actionLoading, setActionLoading] = useState(null); @@ -239,6 +244,7 @@ export function useCommcoach(): CommcoachHookReturn { setError(null); setIsStreaming(true); setStreamingStatus(null); + setStreamingMessage(null); setMessages([]); setSession(null); try { @@ -259,7 +265,7 @@ export function useCommcoach(): CommcoachHookReturn { setMessages(eventData.messages); } } else if (eventType === 'messageChunk' && eventData) { - setStreamingMessage(eventData.accumulated || ''); + setStreamingStatus(prev => prev || 'Coach formuliert Antwort...'); } else if (eventType === 'message' && eventData) { setStreamingMessage(null); const msg: CoachingMessage = { @@ -313,7 +319,7 @@ export function useCommcoach(): CommcoachHookReturn { } }, [instanceId, selectedContextId, ttsPlayback.play]); - const sendMessage = useCallback(async (content: string) => { + const sendMessage = useCallback(async (content: string, options?: SendMessageOptions) => { const normalizedContent = content.trim(); if (!normalizedContent || !instanceId || !session) return; @@ -326,6 +332,8 @@ export function useCommcoach(): CommcoachHookReturn { setError(null); setIsStreaming(true); setStreamingStatus(null); + setStreamingMessage(null); + setAgentToolCalls([]); const tempMsg: CoachingMessage = { id: `temp-${Date.now()}`, @@ -350,7 +358,7 @@ export function useCommcoach(): CommcoachHookReturn { const eventData = event.data; if (eventType === 'messageChunk' && eventData) { - setStreamingMessage(eventData.accumulated || ''); + setStreamingStatus(prev => prev || 'Coach formuliert Antwort...'); } else if (eventType === 'message' && eventData) { setStreamingMessage(null); const msg: CoachingMessage = { @@ -374,6 +382,17 @@ export function useCommcoach(): CommcoachHookReturn { ttsPlayback.play(eventData.audio); } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); + } else if (eventType === 'toolCall' && eventData) { + setAgentToolCalls(prev => [...prev, { toolName: eventData.toolName, args: eventData.args }]); + setStreamingStatus(`Tool: ${eventData.toolName}...`); + } else if (eventType === 'toolResult' && eventData) { + setAgentToolCalls(prev => prev.map((tc, idx) => + idx === prev.length - 1 + ? { ...tc, result: eventData.data?.slice(0, 200), success: eventData.success } + : tc + )); + } else if (eventType === 'agentProgress' && eventData) { + setStreamingStatus(`Runde ${eventData.round}/${eventData.maxRounds}...`); } else if (eventType === 'taskCreated' && eventData) { setTasks(prev => [eventData, ...prev]); } else if (eventType === 'documentCreated' && eventData) { @@ -400,6 +419,7 @@ export function useCommcoach(): CommcoachHookReturn { } }, ac.signal, + options, ); } catch (err: any) { if (err.name === 'AbortError') return; @@ -417,6 +437,7 @@ export function useCommcoach(): CommcoachHookReturn { setError(null); setIsStreaming(true); setStreamingStatus(null); + setStreamingMessage(null); try { await sendAudioStreamApi( instanceId, @@ -427,7 +448,9 @@ export function useCommcoach(): CommcoachHookReturn { const eventType = event.type; const eventData = event.data; - if (eventType === 'status' && eventData) { + if (eventType === 'messageChunk' && eventData) { + setStreamingStatus(prev => prev || 'Coach formuliert Antwort...'); + } else if (eventType === 'status' && eventData) { setStreamingStatus(eventData.label || null); } else if (eventType === 'message' && eventData) { if (eventData.role === 'assistant') setError(null); @@ -555,6 +578,7 @@ export function useCommcoach(): CommcoachHookReturn { session, messages, isStreaming, streamingStatus, streamingMessage, tasks, scores, sessions, error, inputValue, setInputValue, + agentToolCalls, selectContext, createContext, archiveContext, startSession: startSessionCb, sendMessage, sendAudio, diff --git a/src/hooks/useConfirm.tsx b/src/hooks/useConfirm.tsx index bb9fbab..f246ab4 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', }} > @@ -116,7 +116,7 @@ export function useConfirm() { style={{ padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600, border: 'none', - background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)', + background: isDanger ? '#ef4444' : 'var(--primary-color, #F25843)', color: '#fff', 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/useInvitations.ts b/src/hooks/useInvitations.ts index 68f505a..caf9d41 100644 --- a/src/hooks/useInvitations.ts +++ b/src/hooks/useInvitations.ts @@ -49,7 +49,7 @@ export interface Invitation { export interface InvitationCreate { /** Username of the user to invite (optional when email is provided) */ targetUsername?: string; - /** Email address to send invitation link (required for new users) */ + /** Email to send invitation link; optional if targetUsername is set */ email?: string; roleIds: string[]; featureInstanceId?: string; diff --git a/src/hooks/useMandates.ts b/src/hooks/useMandates.ts index a276200..f57c299 100644 --- a/src/hooks/useMandates.ts +++ b/src/hooks/useMandates.ts @@ -15,6 +15,7 @@ import { createMandate as createMandateApi, updateMandate as updateMandateApi, deleteMandate as deleteMandateApi, + hardDeleteMandate as hardDeleteMandateApi, type Mandate, type MandateUpdateData, type PaginationParams @@ -203,6 +204,19 @@ export function useAdminMandates() { } }, [request, fetchMandates]); + // Hard-delete mandate (irreversible) + const handleHardDelete = useCallback(async (mandateId: string, confirmName: string): Promise => { + try { + removeOptimistically(mandateId); + await hardDeleteMandateApi(request, mandateId, confirmName); + return true; + } catch (error: any) { + console.error('Error hard-deleting mandate:', error); + await fetchMandates(); + return false; + } + }, [request, fetchMandates]); + // Inline update const handleInlineUpdate = useCallback(async ( mandateId: string, @@ -231,6 +245,7 @@ export function useAdminMandates() { handleCreate, handleUpdate, handleDelete, + handleHardDelete, handleInlineUpdate, updateOptimistically, }; 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..1f08086 --- /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 } 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/useSubscription.ts b/src/hooks/useSubscription.ts index ea98e6c..8fb6e1a 100644 --- a/src/hooks/useSubscription.ts +++ b/src/hooks/useSubscription.ts @@ -42,10 +42,11 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn { const [plan, setPlan] = useState(null); const [scheduled, setScheduled] = useState(null); const [active, setActive] = useState(false); - const { request, isLoading: loading, error: apiError } = useApiRequest(); + const { request, isLoading: loading, error: apiError, clearCache } = useApiRequest(); const [error, setError] = useState(null); const loadPlans = useCallback(async () => { + clearCache('/api/subscription/plans', 'get'); try { const data = await fetchSelectablePlans(request, mandateId); setPlans(Array.isArray(data) ? data : []); @@ -53,9 +54,10 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn { console.error('Error loading plans:', err); setPlans([]); } - }, [request, mandateId]); + }, [request, mandateId, clearCache]); const loadStatus = useCallback(async () => { + clearCache('/api/subscription/status', 'get'); try { const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId); setActive(data.active); @@ -69,7 +71,7 @@ export function useSubscription(mandateId?: string): UseSubscriptionReturn { setPlan(null); setScheduled(null); } - }, [request, mandateId]); + }, [request, mandateId, clearCache]); const activatePlan = useCallback(async (planKey: string) => { try { 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/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css index faf2439..1bb1c9a 100644 --- a/src/layouts/MainLayout.module.css +++ b/src/layouts/MainLayout.module.css @@ -99,6 +99,7 @@ /* Let child components handle their own scrolling for sticky headers */ overflow: hidden; background: var(--bg-primary, #ffffff); + color: var(--text-primary, #1a1a1a); } /* Fills .content flex column so admin pages get a bounded height for inner scroll */ @@ -168,6 +169,7 @@ :global(.dark-theme) .content { background: var(--bg-dark, #0a0a0a); + color: var(--text-primary, #e5e7eb); } :global(.dark-theme) .mobileMenuButton { diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index dd1cd70..579ab64 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -11,9 +11,11 @@ import { FeatureProvider, useFeatureStore } from '../stores/featureStore'; import { MandateNavigation } from '../components/Navigation/MandateNavigation'; import { UserSection } from '../components/Navigation/UserSection'; import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive'; +import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive'; import styles from './MainLayout.module.css'; const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/; +const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/; // ============================================================================= // INNER LAYOUT (mit Zugriff auf Store) @@ -23,6 +25,9 @@ const MainLayoutInner: React.FC = () => { const { loadFeatures, initialized, loading, error } = useFeatureStore(); const location = useLocation(); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); + const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname); + const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname); + const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible; // Features laden beim Mount useEffect(() => { @@ -105,11 +110,12 @@ const MainLayoutInner: React.FC = () => { />
- + +
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/FeatureView.tsx b/src/pages/FeatureView.tsx index e3ebc03..03567d6 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -219,6 +219,11 @@ export const FeatureViewPage: React.FC = ({ view }) => { return null; } + // CommCoach coaching/dossier is rendered persistently by CommcoachKeepAlive at MainLayout level. + if (featureCode === 'commcoach' && (view === 'coaching' || view === 'dossier')) { + return null; + } + // View-Komponente finden const featureViews = VIEW_COMPONENTS[featureCode]; if (!featureViews) { 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..91dad94 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,13 +22,14 @@ 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); const hasPendingInvitation = !!pendingInvitationToken; - // Get the page the user was trying to visit - const from = location.state?.from?.pathname || "/"; + const fromLocation = location.state?.from; + const from = (fromLocation?.pathname || "/") + (fromLocation?.search || ""); // Set page title and generate CSRF token useEffect(() => { @@ -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/Admin.module.css b/src/pages/admin/Admin.module.css index 00af2c7..bf7c98b 100644 --- a/src/pages/admin/Admin.module.css +++ b/src/pages/admin/Admin.module.css @@ -550,8 +550,8 @@ .statusBadge.starting, .statusBadge.running { - background: #e3f2fd; - color: #1976d2; + background: var(--primary-dark-bg, rgba(242, 88, 67, 0.12)); + color: var(--primary-color, #F25843); } .statusBadge.completed { @@ -617,7 +617,7 @@ } .logStatus { - color: #1976d2; + color: var(--primary-color, #F25843); } .logEntryError .logStatus, diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx index f17c49f..06a6957 100644 --- a/src/pages/admin/AdminMandatesPage.tsx +++ b/src/pages/admin/AdminMandatesPage.tsx @@ -14,15 +14,17 @@ 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'; +import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa'; import styles from './Admin.module.css'; export const AdminMandatesPage: React.FC = () => { const navigate = useNavigate(); const { request } = useApiRequest(); const { showWarning, showSuccess } = useToast(); + const { prompt, PromptDialog } = usePrompt(); const { mandates, columns, @@ -35,6 +37,7 @@ export const AdminMandatesPage: React.FC = () => { handleCreate, handleUpdate, handleDelete, + handleHardDelete, handleInlineUpdate, updateOptimistically, } = useAdminMandates(); @@ -111,15 +114,42 @@ 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}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:`, + { title: 'Mandant deaktivieren', confirmLabel: 'Deaktivieren', variant: 'danger', placeholder: mandate.name }, + ); + if (entered === null) return; + if (entered !== mandate.name) { + showWarning('Abgebrochen', 'Der eingegebene Name stimmt nicht überein.'); + return; } await handleDelete(mandate.id); }; + const handleHardDeleteMandate = async (mandate: Mandate) => { + if (mandate.isSystem) { + showWarning('Nicht erlaubt', 'System-Mandanten können nicht gelöscht werden.'); + return; + } + const entered = await prompt( + `ACHTUNG: Dies löscht den Mandanten "${mandate.name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:`, + { title: 'Hard Delete (irreversibel)', confirmLabel: 'Endgültig löschen', variant: 'danger', placeholder: mandate.name }, + ); + if (entered === null) return; + if (entered !== mandate.name) { + showWarning('Abgebrochen', 'Der eingegebene Name stimmt nicht überein.'); + return; + } + const ok = await handleHardDelete(mandate.id, entered); + if (ok) { + showSuccess('Gelöscht', `Mandant "${mandate.name}" wurde endgültig gelöscht.`); + } + }; + if (error) { return (
@@ -209,12 +239,21 @@ export const AdminMandatesPage: React.FC = () => { }] : []), ...(canDelete ? [{ type: 'delete' as const, - title: 'Löschen', + title: 'Deaktivieren (Soft-Delete)', disabled: (row: Mandate) => row.isSystem ? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' } : false }] : []), ]} + customActions={canDelete ? [{ + id: 'hard-delete', + icon: , + onClick: handleHardDeleteMandate, + title: 'Hard Delete (irreversibel)', + disabled: (row: Mandate) => row.isSystem + ? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' } + : false, + }] : []} onDelete={handleDeleteMandate} hookData={{ refetch, @@ -267,6 +306,8 @@ export const AdminMandatesPage: React.FC = () => {
)} + + {/* Edit Modal */} {editingFormData && (
{ const addInviteeByEmail = () => { const email = inviteeForm.email.trim(); - if (!email) { - setError('E-Mail ist erforderlich'); + const username = inviteeForm.username.trim(); + if (!email && !username) { + setError('Bitte mindestens eine E-Mail-Adresse oder einen Benutzernamen angeben.'); + return; + } + const emailLower = email.toLowerCase(); + const userLower = username.toLowerCase(); + if (email && invitees.some(i => !i.isExisting && (i.email || '').toLowerCase() === emailLower)) { + setError('Diese E-Mail ist bereits in der Liste'); + return; + } + if (username && invitees.some(i => !i.isExisting && (i.username || '').toLowerCase() === userLower)) { + setError('Dieser Benutzername ist bereits in der Liste'); return; } setInvitees(prev => [...prev, { email, - username: undefined, + username: username || undefined, roleIds: [...inviteeForm.roleIds], isExisting: false, }]); @@ -189,10 +200,6 @@ export const AdminInvitationWizardPage: React.FC = () => { const user = allSystemUsers.find(u => u.id === selectedExistingUserId); if (!user) return; const email = (user.email || '').trim(); - if (!email) { - setError('Dieser Benutzer hat keine E-Mail-Adresse'); - return; - } if (invitees.some(i => i.userId === user.id)) { setError('Dieser Benutzer ist bereits in der Liste'); return; @@ -232,8 +239,9 @@ export const AdminInvitationWizardPage: React.FC = () => { const results: DispatchResult[] = []; try { for (const inv of invitees) { + const emailTrim = (inv.email || '').trim(); const payload = { - email: inv.email, + ...(emailTrim ? { email: emailTrim } : {}), targetUsername: inv.username || undefined, roleIds: inv.roleIds, expiresInHours: EXPIRES_IN_HOURS, @@ -244,14 +252,14 @@ export const AdminInvitationWizardPage: React.FC = () => { const result = await createInvitation(selectedMandate.id, payload); if (result.success) { results.push({ - email: inv.email, + email: emailTrim, username: inv.username, success: true, emailSent: result.data?.emailSent, }); } else { results.push({ - email: inv.email, + email: emailTrim, username: inv.username, success: false, error: result.error, @@ -452,7 +460,7 @@ export const AdminInvitationWizardPage: React.FC = () => {

Einladungen hinzufügen

- E-Mail ist erforderlich. Neue Benutzer legen ihren Benutzernamen beim Annehmen der Einladung selbst fest. Sie können neue Benutzer per E-Mail oder bestehende Benutzer hinzufügen. + Für neue Benutzer: mindestens eine E-Mail oder ein Benutzername (vorgegeben). Ohne E-Mail wird kein Link per Mail versendet — der Einladungslink kann manuell geteilt werden. Bestehende Benutzer wählen Sie im zweiten Tab.

{/* Add form: toggle email vs existing */} @@ -462,7 +470,7 @@ export const AdminInvitationWizardPage: React.FC = () => { style={{ fontSize: '12px', padding: '6px 12px' }} onClick={() => setAddMode('email')} > - Per E-Mail (neue Benutzer) + Neue Benutzer (E-Mail und/oder Benutzername) @@ -552,7 +574,7 @@ export const AdminInvitationWizardPage: React.FC = () => { display: 'flex', alignItems: 'center', gap: '6px', padding: '6px 12px', borderRadius: '6px', background: inviteeForm.roleIds.includes(r.id) ? '#dbeafe' : 'var(--bg-secondary, #f8fafc)', - border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #3b82f6)' : 'var(--border-color, #e2e8f0)'}`, + border: `1px solid ${inviteeForm.roleIds.includes(r.id) ? 'var(--primary-color, #F25843)' : 'var(--border-color, #e2e8f0)'}`, fontSize: '12px', cursor: 'pointer', }}> { - + @@ -596,14 +618,16 @@ export const AdminInvitationWizardPage: React.FC = () => { {invitees.map((inv, idx) => ( - - + + - +
E-MailE-Mail / Benutzer Benutzername Rollen Typ
{inv.email}{inv.isExisting ? inv.username : ''}{inv.email || '—'} + {inv.username || ''} + {inv.roleIds.length > 0 ? roles.filter(r => inv.roleIds.includes(r.id)).map(r => r.roleLabel).join(', ') : '-'} {inv.isExisting ? 'Bestehend' : 'Neu'}{inv.isExisting ? 'Bestehend' : 'Neu (Einladung)'}