diff --git a/eslint.config.js b/eslint.config.js index 092408a..10a64ff 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,17 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], + 'no-restricted-imports': [ + 'warn', + { + patterns: [ + { + group: ['**/components/FolderTree/FolderTree*', '**/FolderTree/FolderTree*'], + message: 'FolderTree is deprecated — use FormGeneratorTable with groupingConfig instead.', + }, + ], + }, + ], }, }, ) diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index b93ce33..41a79e4 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -4,6 +4,22 @@ import { ApiRequestOptions } from '../hooks/useApi'; // TYPES & INTERFACES // ============================================================================ +export interface KnowledgePreferences { + schemaVersion?: number; + neutralizeBeforeEmbed?: boolean; + mailContentDepth?: 'metadata' | 'snippet' | 'full'; + mailIndexAttachments?: boolean; + filesIndexBinaries?: boolean; + mimeAllowlist?: string[]; + clickupScope?: 'titles' | 'title_description' | 'with_comments'; + clickupIndexAttachments?: boolean; + surfaceToggles?: { + google?: { gmail?: boolean; drive?: boolean }; + msft?: { sharepoint?: boolean; outlook?: boolean }; + }; + maxAgeDays?: number; +} + export interface Connection { id: string; userId: string; @@ -15,6 +31,8 @@ export interface Connection { connectedAt: number; // Backend uses float for UTC timestamp in seconds lastChecked: number; // Backend uses float for UTC timestamp in seconds expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds + knowledgeIngestionEnabled?: boolean; + knowledgePreferences?: KnowledgePreferences | null; [key: string]: any; // Allow additional properties } @@ -37,6 +55,19 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + /** Scope request to items of this group (resolved server-side to itemIds IN-filter). */ + groupId?: string; + /** If set, persist this group tree on the backend before fetching (optimistic save). */ + saveGroupTree?: TableGroupNode[]; +} + +export interface TableGroupNode { + id: string; + name: string; + itemIds: string[]; + subGroups: TableGroupNode[]; + order: number; + isExpanded: boolean; } export interface PaginatedResponse { @@ -47,6 +78,8 @@ export interface PaginatedResponse { totalItems: number; totalPages: number; }; + /** Current group tree for this (user, contextKey) pair — undefined if no grouping configured. */ + groupTree?: TableGroupNode[]; } export interface CreateConnectionData { @@ -58,6 +91,8 @@ export interface CreateConnectionData { externalUsername?: string; externalEmail?: string; status?: 'active' | 'expired' | 'revoked' | 'pending'; + knowledgeIngestionEnabled?: boolean; + knowledgePreferences?: KnowledgePreferences | null; connectedAt?: number; lastChecked?: number; expiresAt?: number; @@ -103,6 +138,8 @@ export async function fetchConnections( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 18dc47e..e251006 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -34,6 +34,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -103,6 +105,8 @@ export async function fetchFiles( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); @@ -186,110 +190,87 @@ export async function deleteFiles( return uniqueIds.map(fileId => ({ success: true, fileId })); } -export async function deleteFolders( - request: ApiRequestFunction, - folderIds: string[], - recursiveFolders: boolean = true -): Promise<{ deletedFiles: number; deletedFolders: number }> { - const uniqueIds = [...new Set(folderIds.filter(Boolean))]; - if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 }; - return await request({ - url: '/api/files/batch-delete', - method: 'post', - data: { folderIds: uniqueIds, recursiveFolders } - }); -} - // ============================================================================ -// FOLDER API FUNCTIONS +// GROUP BULK API FUNCTIONS // ============================================================================ -export interface FolderInfo { - id: string; - name: string; - parentId: string | null; - fileCount?: number; - mandateId?: string; - featureInstanceId?: string; - createdAt?: number; - scope?: string; - neutralize?: boolean; -} - -export async function fetchFolders( +/** Patch scope for all files in a group (recursive) */ +export async function patchGroupScope( request: ApiRequestFunction, - parentId?: string | null -): Promise { - const params: any = {}; - if (parentId !== undefined && parentId !== null) { - params.parentId = parentId; - } - const data = await request({ - url: '/api/files/folders', - method: 'get', - params, - }); - return Array.isArray(data) ? data : []; -} - -export async function createFolder( - request: ApiRequestFunction, - name: string, - parentId?: string | null -): Promise { - return await request({ - url: '/api/files/folders', - method: 'post', - data: { name, parentId: parentId || null }, - }); -} - -export async function renameFolder( - request: ApiRequestFunction, - folderId: string, - name: string + groupId: string, + scope: string ): Promise { return await request({ - url: `/api/files/folders/${folderId}`, - method: 'put', - data: { name }, + url: `/api/files/groups/${groupId}/scope`, + method: 'patch', + data: { scope }, }); } -export async function deleteFolderApi( +/** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */ +export async function patchGroupNeutralize( request: ApiRequestFunction, - folderId: string, - recursive: boolean = false + groupId: string, + neutralize: boolean ): Promise { return await request({ - url: `/api/files/folders/${folderId}`, + url: `/api/files/groups/${groupId}/neutralize`, + method: 'patch', + data: { neutralize }, + }); +} + +/** Download all files in a group as ZIP */ +export async function downloadGroupZip(groupId: string): Promise { + const { default: api } = await import('../api'); + const response = await api.get(`/api/files/groups/${groupId}/download`, { + responseType: 'blob', + }); + const url = window.URL.createObjectURL(response.data); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `group-${groupId}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); +} + +/** Delete a group and optionally all its files */ +export async function deleteGroup( + request: ApiRequestFunction, + groupId: string, + deleteItems: boolean = false +): Promise { + return await request({ + url: `/api/files/groups/${groupId}`, method: 'delete', - params: { recursive }, + params: { deleteItems }, }); } -export async function moveFolder( - request: ApiRequestFunction, - folderId: string, - targetParentId: string | null -): Promise { - return await request({ - url: `/api/files/folders/${folderId}/move`, - method: 'post', - data: { targetParentId }, - }); -} - -export async function moveFile( - request: ApiRequestFunction, - fileId: string, - targetFolderId: string | null -): Promise { - return await request({ - url: `/api/files/${fileId}/move`, - method: 'post', - data: { targetFolderId }, - }); +/** Collect all file IDs belonging to a group recursively (client-side, from known groupTree) */ +export function collectGroupItemIds( + groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>, + groupId: string +): string[] { + const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => { + for (const node of nodes) { + if (node.id === groupId) { + const ids: string[] = [...node.itemIds]; + const sub = (n: { id: string; itemIds: string[]; subGroups: any[] }) => { + ids.push(...n.itemIds); + n.subGroups.forEach(sub); + }; + node.subGroups.forEach(sub); + return ids; + } + const found = collect(node.subGroups); + if (found) return found; + } + return null; + }; + return collect(groupTree) ?? []; } // Note: The following operations require special handling (FormData, blob responses) diff --git a/src/api/mandateApi.ts b/src/api/mandateApi.ts index 38bf41c..7946395 100644 --- a/src/api/mandateApi.ts +++ b/src/api/mandateApi.ts @@ -46,6 +46,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -84,6 +86,8 @@ export async function fetchMandates( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/promptApi.ts b/src/api/promptApi.ts index 00f1be7..e735ae0 100644 --- a/src/api/promptApi.ts +++ b/src/api/promptApi.ts @@ -49,6 +49,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -110,6 +112,8 @@ export async function fetchPrompts( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/api/userApi.ts b/src/api/userApi.ts index d16bf38..98dd7a2 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -48,6 +48,8 @@ export interface PaginationParams { sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; filters?: Record; search?: string; + groupId?: string; + saveGroupTree?: any[]; } export interface PaginatedResponse { @@ -152,6 +154,8 @@ export async function fetchUsers( if (params.sort) paginationObj.sort = params.sort; if (params.filters) paginationObj.filters = params.filters; if (params.search) paginationObj.search = params.search; + if (params.groupId) paginationObj.groupId = params.groupId; + if (params.saveGroupTree !== undefined) paginationObj.saveGroupTree = params.saveGroupTree; if (Object.keys(paginationObj).length > 0) { requestParams.pagination = JSON.stringify(paginationObj); diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.module.css b/src/components/AddConnectionWizard/AddConnectionWizard.module.css new file mode 100644 index 0000000..5cabd64 --- /dev/null +++ b/src/components/AddConnectionWizard/AddConnectionWizard.module.css @@ -0,0 +1,467 @@ +/* AddConnectionWizard styles */ + +.stepper { + display: flex; + justify-content: center; + gap: 1.5rem; + padding: 1rem 1.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.stepDot { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + background: var(--bg-secondary, #f0f0f0); + color: var(--text-secondary, #666); + border: 2px solid var(--border-color, #ddd); + transition: background 0.2s, border-color 0.2s, color 0.2s; +} + +.stepDotActive { + background: var(--primary-color, #f25843); + border-color: var(--primary-color, #f25843); + color: white; +} + +.stepDotDone { + background: var(--success-color, #22c55e); + border-color: var(--success-color, #22c55e); + color: white; +} + +.stepDotHidden { + opacity: 0.3; +} + +.body { + padding: 1.5rem; + overflow-y: auto; +} + +.stepContent { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 220px; +} + +.stepTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.stepBody { + font-size: 0.9375rem; + color: var(--text-primary); + line-height: 1.6; + margin: 0; +} + +.stepHint { + font-size: 0.8125rem; + color: var(--text-secondary, #666); + margin: 0; +} + +/* Connector grid (Step 0) */ +.connectorGrid { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.connectorCard { + flex: 1 1 140px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.625rem; + padding: 1.25rem 1rem; + background: var(--surface-color); + border: 2px solid var(--border-color, #ddd); + border-radius: 10px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s; +} + +.connectorCard:hover { + border-color: var(--primary-color, #f25843); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} + +.connectorIcon { + font-size: 1.75rem; +} + +.connectorLabel { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +/* Consent step (Step 1) */ +.consentIcon { + display: flex; + justify-content: center; + color: var(--primary-color, #f25843); +} + +.consentButtons { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.consentButtonYes { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.consentButtonYes:hover { + background: var(--primary-dark, #d94d3a); +} + +.consentButtonNo { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--surface-color); + color: var(--text-primary); + border: 2px solid var(--border-color, #ddd); + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.consentButtonNo:hover { + border-color: var(--text-secondary, #888); + background: var(--bg-secondary, #f5f5f5); +} + +/* Preferences step (Step 2) */ +.prefGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-color, #eee); +} + +.prefGroup:last-of-type { + border-bottom: none; +} + +.prefLabel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.9375rem; + color: var(--text-primary); + cursor: pointer; + font-weight: 500; +} + +.prefLabelRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.9375rem; + color: var(--text-primary); + font-weight: 500; +} + +.prefIcon { + color: var(--text-secondary, #666); + font-size: 0.875rem; +} + +.prefCheck { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--primary-color, #f25843); +} + +.prefSelect { + padding: 0.375rem 0.5rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + background: var(--surface-color); + color: var(--text-primary); + min-width: 200px; +} + +.prefNumber { + width: 80px; + padding: 0.375rem 0.5rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + background: var(--surface-color); + color: var(--text-primary); + text-align: right; +} + +.prefHint { + font-size: 0.8125rem; + color: var(--text-secondary, #666); + margin: 0; +} + +/* Summary step (Step 3) */ +.summary { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; + overflow: hidden; +} + +.summaryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 1rem; + gap: 1rem; + border-bottom: 1px solid var(--border-color, #eee); +} + +.summaryRow:last-child { + border-bottom: none; +} + +.summaryKey { + font-size: 0.875rem; + color: var(--text-secondary, #666); + font-weight: 500; +} + +.summaryVal { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; +} + +/* Back button (step 1 consent screen) */ +.stepNavLeft { + margin-top: 0.75rem; + display: flex; +} + +.navBack { + background: none; + border: none; + padding: 0.25rem 0; + font-size: 0.8125rem; + color: var(--text-secondary, #666); + cursor: pointer; + text-decoration: underline; +} + +.navBack:hover { + color: var(--text-primary); +} + +/* Cost estimate hint */ +.costHint { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.75rem 1rem; + background: var(--info-bg, #eff6ff); + border: 1px solid var(--info-border, #bfdbfe); + border-radius: 8px; + font-size: 0.8125rem; +} + +.costHintIcon { + flex-shrink: 0; + margin-top: 2px; + color: var(--info-color, #3b82f6); +} + +.costHint > div { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; +} + +.costHintTitle { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.125rem; +} + +.costTable { + border-collapse: collapse; + width: 100%; + font-size: 0.8125rem; +} + +.costLabel { + color: var(--text-secondary, #555); + padding-right: 1rem; + white-space: nowrap; +} + +.costVal { + font-weight: 600; + color: var(--info-color, #1d4ed8); +} + +.costRowNeut .costLabel, +.costRowNeut .costVal { + padding-top: 0.125rem; +} + +.costRowNeut .costVal { + color: #b45309; +} + +.costHintWarn { + font-size: 0.75rem; + color: #b45309; + font-weight: 500; + line-height: 1.4; +} + +.costHintNote { + color: var(--text-secondary, #555); + font-size: 0.75rem; +} + +:global(.dark-theme) .costHint { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.3); +} + +:global(.dark-theme) .costVal { + color: #93c5fd; +} + +:global(.dark-theme) .costRowNeut .costVal, +:global(.dark-theme) .costHintWarn { + color: #fbbf24; +} + +/* Navigation */ +.stepNav { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; + padding-top: 0.5rem; + gap: 0.75rem; +} + +.navBack { + padding: 0.5rem 1rem; + background: var(--surface-color); + color: var(--text-secondary, #666); + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.navBack:hover { + background: var(--bg-secondary, #f5f5f5); +} + +.navNext { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1.25rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.navNext:hover { + background: var(--primary-dark, #d94d3a); +} + +.navConnect { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.625rem 1.5rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 6px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.navConnect:hover:not(:disabled) { + background: var(--primary-dark, #d94d3a); +} + +.navConnect:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Dark theme */ +:global(.dark-theme) .connectorCard { + background: var(--surface-color); +} + +:global(.dark-theme) .prefSelect, +:global(.dark-theme) .prefNumber { + background: var(--surface-color); + color: var(--text-primary); +} + +:global(.dark-theme) .summary { + border-color: var(--border-color); +} + +:global(.dark-theme) .summaryRow { + border-color: var(--border-color); +} diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx new file mode 100644 index 0000000..85c9336 --- /dev/null +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -0,0 +1,520 @@ +/** + * AddConnectionWizard + * + * Multi-step modal for adding a new connector with optional knowledge + * ingestion consent and per-connection preferences (§2.6). + * + * Steps: + * 0 — Connector wählen + * 1 — Consent (Wissensdatenbank Ja/Nein) + * 2 — Präferenzen (nur wenn Ja) + * 3 — Zusammenfassung + OAuth starten + */ + +import React, { useState } from 'react'; +import { Modal } from '../UiComponents/Modal/Modal'; +import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa'; +import type { KnowledgePreferences } from '../../api/connectionApi'; +import styles from './AddConnectionWizard.module.css'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ConnectorType = 'google' | 'msft' | 'clickup'; + +interface WizardState { + step: 0 | 1 | 2 | 3; + connector: ConnectorType | null; + knowledgeEnabled: boolean; + prefs: KnowledgePreferences; +} + +const DEFAULT_PREFS: KnowledgePreferences = { + schemaVersion: 1, + neutralizeBeforeEmbed: false, + mailContentDepth: 'full', + mailIndexAttachments: false, + filesIndexBinaries: true, + clickupScope: 'title_description', + clickupIndexAttachments: false, + maxAgeDays: 90, +}; + +const CONNECTOR_LABELS: Record = { + google: 'Google', + msft: 'Microsoft 365', + clickup: 'ClickUp', +}; + +const CONNECTOR_ICONS: Record = { + google: , + msft: , + clickup: , +}; + +// --------------------------------------------------------------------------- +// Cost estimate helper +// --------------------------------------------------------------------------- + +/** + * Returns a cost estimate broken into two lines: + * + * 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) — always tiny. + * 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call) + * — this is the DOMINANT cost when enabled. One call per email/task for + * short content; several calls for long threads or files. + * + * Numbers are conservative ranges. Subsequent syncs are cheaper because + * unchanged content is deduplicated before any LLM/embedding call. + */ +function computeCostEstimate( + connector: ConnectorType | null, + prefs: KnowledgePreferences, +): { + embeddingLow: string; + embeddingHigh: string; + neutralizationLow: string | null; + neutralizationHigh: string | null; + note: string; +} | null { + if (!connector) return null; + + // ---- Embedding (OpenAI, USD) ---- + const EMBED_USD_PER_M = 0.02; + const tokensPerMail: Record = { metadata: 30, snippet: 120, full: 500 }; + const depth = prefs.mailContentDepth ?? 'full'; + const maxAge = prefs.maxAgeDays ?? 90; + const mailCount = Math.min(500, Math.round((maxAge / 90) * 500)); + const taskCount = Math.min(500, Math.round((maxAge / 90) * 300)); + + let embedLowTokens = 0; + let embedHighTokens = 0; + + if (connector === 'google' || connector === 'msft') { + const mailTokens = mailCount * tokensPerMail[depth]; + embedLowTokens += mailTokens * 0.6; + embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint + if (prefs.mailIndexAttachments) embedHighTokens += 200_000; + } else if (connector === 'clickup') { + const scope = prefs.clickupScope ?? 'title_description'; + const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400; + embedLowTokens += taskCount * tpt * 0.6; + embedHighTokens += taskCount * tpt * 1.5; + } + + const fmtUsd = (tokens: number) => { + const usd = (tokens / 1_000_000) * EMBED_USD_PER_M; + if (usd < 0.001) return '< 0.01 $'; + if (usd < 0.10) return `~${usd.toFixed(3)} $`; + return `~${usd.toFixed(2)} $`; + }; + + // ---- Neutralization (Private LLM, CHF 0.01/call) ---- + // Each item (email / task / file) = 1 LLM call for short content, + // 2-4 for long threads/documents. + const NEUT_CHF_PER_CALL = 0.01; + let neutLow: string | null = null; + let neutHigh: string | null = null; + + if (prefs.neutralizeBeforeEmbed) { + let lowCalls = 0; + let highCalls = 0; + + if (connector === 'google' || connector === 'msft') { + lowCalls += mailCount * 1; // 1 call / short email + highCalls += mailCount * 3; // up to 3 calls / long thread + lowCalls += 20; // Drive/SharePoint files (low) + highCalls += 200; // Drive/SharePoint files (high, large PDFs) + } else if (connector === 'clickup') { + lowCalls += taskCount * 1; + highCalls += taskCount * 2; + } + + const fmtChf = (calls: number) => { + const chf = calls * NEUT_CHF_PER_CALL; + if (chf < 0.01) return '< 0.01 CHF'; + return `~${chf.toFixed(2)} CHF`; + }; + + neutLow = fmtChf(lowCalls); + neutHigh = fmtChf(highCalls); + } + + return { + embeddingLow: fmtUsd(embedLowTokens), + embeddingHigh: fmtUsd(embedHighTokens), + neutralizationLow: neutLow, + neutralizationHigh: neutHigh, + note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).', + }; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface AddConnectionWizardProps { + open: boolean; + onClose: () => void; + onConnect: ( + type: ConnectorType, + knowledgeEnabled: boolean, + prefs: KnowledgePreferences | null, + ) => Promise; + isConnecting?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export const AddConnectionWizard: React.FC = ({ + open, + onClose, + onConnect, + isConnecting = false, +}) => { + const [state, setState] = useState({ + step: 0, + connector: null, + knowledgeEnabled: false, + prefs: { ...DEFAULT_PREFS }, + }); + + const reset = () => + setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } }); + + const handleClose = () => { + reset(); + onClose(); + }; + + const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step })); + const setConnector = (connector: ConnectorType) => + setState(s => ({ ...s, connector, step: 1 })); + const setKnowledgeEnabled = (v: boolean) => + setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 })); + const updatePref = (key: K, value: KnowledgePreferences[K]) => + setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } })); + + const handleConnect = async () => { + if (!state.connector) return; + await onConnect( + state.connector, + state.knowledgeEnabled, + state.knowledgeEnabled ? state.prefs : null, + ); + reset(); + onClose(); + }; + + const visibleSteps = state.knowledgeEnabled + ? [0, 1, 2, 3] + : [0, 1, 3]; + + return ( + + {/* Stepper */} +
+ {[0, 1, 2, 3].map(i => ( +
i ? styles.stepDotDone : '', + !visibleSteps.includes(i) ? styles.stepDotHidden : '', + ].join(' ')} + > + {state.step > i ? : i + 1} +
+ ))} +
+ +
+ {/* ---- Step 0: Connector ---- */} + {state.step === 0 && ( +
+

Anbieter wählen

+

Welchen Dienst möchtest du verbinden?

+
+ {(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( + + ))} +
+
+ )} + + {/* ---- Step 1: Consent ---- */} + {state.step === 1 && ( +
+
+

Wissensdatenbank

+

+ Möchtest du Inhalte aus dieser Verbindung in deine persönliche + Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen + aus{' '} + {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '} + zurückgreifen kann? +

+

+ Du kannst diese Einstellung später in den Verbindungsdetails ändern. +

+
+ + +
+
+ +
+
+ )} + + {/* ---- Step 2: Preferences ---- */} + {state.step === 2 && ( +
+

Einstellungen

+

+ Steuere, welche Inhalte und in welcher Form sie indexiert werden. +

+ +
+ +

+ Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt. +

+
+ + {(state.connector === 'google' || state.connector === 'msft') && ( + <> +
+ +
+
+ +
+ + )} + + {state.connector === 'clickup' && ( +
+ +
+ )} + +
+ +

0 = kein Limit

+
+ +
+ + +
+
+ )} + + {/* ---- Step 3: Summary ---- */} + {state.step === 3 && ( +
+

Zusammenfassung

+
+
+ Anbieter + + {CONNECTOR_ICONS[state.connector!]}  + {state.connector ? CONNECTOR_LABELS[state.connector] : '—'} + +
+
+ Wissensdatenbank + + {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'} + +
+ {state.knowledgeEnabled && ( + <> +
+ Anonymisierung + + {state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'} + +
+ {(state.connector === 'google' || state.connector === 'msft') && ( +
+ E-Mail-Tiefe + + {{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[ + state.prefs.mailContentDepth ?? 'full' + ] ?? state.prefs.mailContentDepth} + +
+ )} + {state.connector === 'clickup' && ( +
+ Aufgaben-Inhalt + + {{ + titles: 'Nur Titel', + title_description: 'Titel + Beschreibung', + with_comments: 'Titel + Beschreibung + Kommentare', + }[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope} + +
+ )} +
+ Zeitfenster + + {state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'} + +
+ + )} +
+ + {/* Cost estimate — only shown when knowledge ingestion is enabled */} + {state.knowledgeEnabled && (() => { + const est = computeCostEstimate(state.connector, state.prefs); + if (!est) return null; + return ( +
+ +
+ Geschätzte Kosten (erster Sync) + + + + + + + {est.neutralizationLow && ( + + + + + )} + +
Embedding + {est.embeddingLow} – {est.embeddingHigh} +
Anonymisierung (Private LLM) + {est.neutralizationLow} – {est.neutralizationHigh} +
+ {est.neutralizationLow && ( + + ⚠ Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise). + + )} + {est.note} +
+
+ ); + })()} + +
+ + +
+
+ )} +
+
+ ); +}; + +export default AddConnectionWizard; diff --git a/src/components/FlowEditor/editor/EditorChatPanel.tsx b/src/components/FlowEditor/editor/EditorChatPanel.tsx index 84064ff..3ce248e 100644 --- a/src/components/FlowEditor/editor/EditorChatPanel.tsx +++ b/src/components/FlowEditor/editor/EditorChatPanel.tsx @@ -4,7 +4,7 @@ * AI Chat sidebar for the GraphicalEditor. * Streams responses via SSE (same pattern as Workspace chat). * File & data-source attachment UX mirrors WorkspaceInput: - * - Files: drag & drop from FolderTree onto input area, or click in UDB + * - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB * - Data Sources: 🔗 picker button next to input (toggle-select from active sources) */ import React, { useState, useCallback, useEffect, useRef } from 'react'; @@ -32,7 +32,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext'; export interface PendingFile { fileId: string; fileName: string; - itemType?: 'file' | 'folder'; + itemType?: 'file' | 'group'; } export interface EditorDataSource { @@ -241,7 +241,12 @@ export const EditorChatPanel: React.FC = ({ instanceId, }, [_handleSend]); const _handleDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/tree-items')) { + if ( + e.dataTransfer.types.includes('application/tree-items') || + e.dataTransfer.types.includes('application/group-id') || + e.dataTransfer.types.includes('application/file-id') || + e.dataTransfer.types.includes('application/file-ids') + ) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setTreeDropOver(true); @@ -252,6 +257,12 @@ export const EditorChatPanel: React.FC = ({ instanceId, const _handleDrop = useCallback((e: React.DragEvent) => { setTreeDropOver(false); + const groupId = e.dataTransfer.getData('application/group-id'); + if (groupId) { + e.preventDefault(); + e.stopPropagation(); + return; + } const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson) { e.preventDefault(); @@ -282,11 +293,11 @@ export const EditorChatPanel: React.FC = ({ instanceId, - {pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName} + {pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName} {onRemovePendingFile && ( )} + {groupingEnabled && onCreateGroup && ( + + )} {onRefresh && ( + + {/* Folder icon */} + + {isExpanded ? : } + + + {/* Name / inline input */} + {isEditing ? ( + { + if (e.key === 'Enter') onEditCommit(e.currentTarget.value); + if (e.key === 'Escape') onEditCancel(); + }} + onBlur={(e) => onEditCommit(e.target.value)} + /> + ) : ( + { e.stopPropagation(); onToggle(); }}> + {node.name || {t('(Unbenannt)')}} + + )} + + {/* Item count badge */} + {!isEditing && ( + + {visibleCount < totalCount && totalCount > 0 + ? `${visibleCount} / ${totalCount}` + : String(totalCount)} + + )} + + {/* Drop hint */} + {(isDragOver || isDragOverFromGroup) && ( + + {isDragOverFromGroup ? t('Als Untergruppe ablegen') : t('Hierher ziehen')} + + )} + + {/* ── Bulk actions (delete all, custom batch) right after badge ── */} + {!isEditing && bulkActions.length > 0 && ( + <> + + + {bulkActions.map((action, i) => ( + + ))} + + + )} + + {/* ── Group management: rename / add-subgroup ── */} + {!isEditing && ( + + + + + )} + + + + + + + ); + + return folderCells; +} + +// --------------------------------------------------------------------------- +// BreadcrumbRow +// --------------------------------------------------------------------------- + +interface BreadcrumbRowProps { + groupName: string; + totalItems: number; + colSpan: number; + onBack: () => void; +} + +export function BreadcrumbRow({ groupName, totalItems, colSpan, onBack }: BreadcrumbRowProps) { + const { t } = useLanguage(); + return ( + + +
+ + + {groupName} + {totalItems > 0 && ( + + ({totalItems} {t('Einträge')}) + + )} +
+ + + ); +} + +// --------------------------------------------------------------------------- +// UngroupedRow — also a drop zone for removing items/groups from groups +// --------------------------------------------------------------------------- + +interface UngroupedRowProps { + count: number; + colSpan: number; + isDragOver?: boolean; + onDragOver?: (e: React.DragEvent) => void; + onDrop?: (e: React.DragEvent) => void; + onDragLeave?: () => void; +} + +export function UngroupedRow({ count, colSpan, isDragOver, onDragOver, onDrop, onDragLeave }: UngroupedRowProps) { + const { t } = useLanguage(); + return ( + e.preventDefault()} + > + + + {t('Nicht zugeordnet')} + {count} + {isDragOver && {t('Aus Gruppe entfernen')}} + + + ); +} diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 08bd0fa..75ba482 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,27 +1,36 @@ -import React, { useState, useCallback, useRef, useMemo } from 'react'; -import { FaFileImport } from 'react-icons/fa'; +import React, { useCallback, useRef, useMemo } from 'react'; +import { FaFileImport, FaPaperPlane } from 'react-icons/fa'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; -import FolderTree from '../../components/FolderTree/FolderTree'; -import type { FileNode } from '../../components/FolderTree/FolderTree'; -import type { FileAction } from '../../components/FolderTree/actions/types'; -import { useFileContext } from '../../contexts/FileContext'; +import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; import { useApiRequest } from '../../hooks/useApi'; import { importWorkflowFromFile, WORKFLOW_FILE_EXTENSION, } from '../../api/workflowApi'; import { useToast } from '../../contexts/ToastContext'; +import { FormGeneratorTable } from '../FormGenerator/FormGeneratorTable'; +import { ViewActionButton } from '../FormGenerator/ActionButtons/ViewActionButton'; +import actionBtnStyles from '../FormGenerator/ActionButtons/ActionButton.module.css'; import styles from './FilesTab.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import type { TableGroupNode } from '../../api/connectionApi'; + +function _findGroupDisplayName(nodes: TableGroupNode[], groupId: string): string | null { + for (const n of nodes) { + if (n.id === groupId) return (n.name && n.name.trim()) || groupId; + const sub = _findGroupDisplayName(n.subGroups, groupId); + if (sub !== null) return sub; + } + return null; +} interface FilesTabProps { context: UdbContext; onFileSelect?: (fileId: string, fileName?: string) => void; - onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; + onSendToChat?: (items: Array<{ id: string; type: 'file' | 'group'; name: string }>) => void; /** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in - * den Graph-Editor importiert wurde. Aktivierung im Editor (Refresh-Liste, - * Auto-Select) bleibt Aufgabe des Aufrufers. */ + * den Graph-Editor importiert wurde. */ onWorkflowImported?: (workflowId: string) => void; } @@ -29,57 +38,23 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat const { t } = useLanguage(); const { request } = useApiRequest(); const { showSuccess, showError } = useToast(); - const [searchQuery, setSearchQuery] = useState(''); - const [isDragOver, setIsDragOver] = useState(false); - const [uploading, setUploading] = useState(false); - const [selectedFolderId, setSelectedFolderId] = useState(null); + const [isDragOver, setIsDragOver] = React.useState(false); + const [uploading, setUploading] = React.useState(false); const fileInputRef = useRef(null); const { - folders, - refreshFolders, - treeFileNodes, - treeFilesLoading, - refreshTreeFiles, - updateTreeFileNode, - expandedFolderIds, - toggleFolderExpanded, - handleCreateFolder, - handleRenameFolder, - handleDeleteFolder, - handleMoveFolder, - handleMoveFolders, - handleMoveFile, - handleMoveFiles: contextMoveFiles, - handleFileDelete, - handleDownloadFolder, - } = useFileContext(); + data: files, + pagination, + loading, + refetch, + groupTree, + } = useUserFiles(); - const _folderNodes = useMemo(() => { - return folders.map(f => ({ - id: f.id, - name: f.name, - parentId: f.parentId ?? null, - fileCount: f.fileCount ?? 0, - neutralize: f.neutralize ?? false, - scope: f.scope ?? 'personal', - })); - }, [folders]); + const { handleFileDelete, previewingFiles } = useFileOperations() as any; - const _fileNodes: FileNode[] = useMemo(() => { - let result = treeFileNodes; - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter(f => - f.fileName.toLowerCase().includes(q), - ); - } - return result; - }, [treeFileNodes, searchQuery]); - - const _refreshAll = useCallback(async () => { - await Promise.all([refreshTreeFiles(), refreshFolders()]); - }, [refreshTreeFiles, refreshFolders]); + const _tableRefetch = useCallback(async (params?: any) => { + await refetch(params); + }, [refetch]); const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { if (!context.instanceId || uploading) return; @@ -93,13 +68,13 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat headers: { 'Content-Type': 'multipart/form-data' }, }); } - await _refreshAll(); + await _tableRefetch(); } catch (err) { console.error('File upload failed:', err); } finally { setUploading(false); } - }, [context.instanceId, uploading, _refreshAll]); + }, [context.instanceId, uploading, _tableRefetch]); const _handleDragOver = useCallback((e: React.DragEvent) => { if (e.dataTransfer.types.includes('Files')) { @@ -131,97 +106,63 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat } }, [_uploadFiles]); - const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { - await handleMoveFile(fileId, targetFolderId); - }, [handleMoveFile]); - const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { - await contextMoveFiles(fileIds, targetFolderId); - }, [contextMoveFiles]); + const columns = useMemo(() => [{ + key: 'fileName', + label: t('Dateiname'), + sortable: false, + filterable: false, + searchable: false, + formatter: (value: any, row: any) => ( +
+ {}} + idField="id" + nameField="fileName" + typeField="mimeType" + loadingStateName="previewingFiles" + hookData={{ previewingFiles }} + className={actionBtnStyles.compact} + /> + + {value} + +
+ ), + }], [t, previewingFiles]); - const _onDeleteFolder = useCallback(async (folderId: string) => { - await handleDeleteFolder(folderId); - if (selectedFolderId === folderId) setSelectedFolderId(null); - }, [handleDeleteFolder, selectedFolderId]); + const _groupBulkActionsProvider = useMemo(() => { + if (!onSendToChat) return undefined; + return (groupId: string, itemIds: string[]) => [ + { + icon: , + title: t('Gruppe an Chat anhängen'), + onClick: () => { + const name = _findGroupDisplayName(groupTree, groupId) ?? groupId; + onSendToChat([{ id: groupId, type: 'group', name }]); + }, + disabled: itemIds.length === 0, + }, + ]; + }, [onSendToChat, groupTree, t]); - const _onRenameFile = useCallback(async (fileId: string, newName: string) => { - await api.put(`/api/files/${fileId}`, { fileName: newName }); - await refreshTreeFiles(); - }, [refreshTreeFiles]); - - const _onDeleteFile = useCallback(async (fileId: string) => { - await handleFileDelete(fileId); - }, [handleFileDelete]); - - const _onDeleteFiles = useCallback(async (fileIds: string[]) => { - await api.post('/api/files/batch-delete', { fileIds }); - await Promise.all([refreshTreeFiles(), refreshFolders()]); - }, [refreshTreeFiles, refreshFolders]); - - const _onDeleteFolders = useCallback(async (folderIds: string[]) => { - await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); - await Promise.all([refreshFolders(), refreshTreeFiles()]); - }, [refreshFolders, refreshTreeFiles]); - - const _onScopeChange = useCallback(async (fileId: string, newScope: string) => { - updateTreeFileNode(fileId, { scope: newScope }); - try { - await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); - } catch (err) { - console.error('Failed to update scope:', err); - await refreshTreeFiles(); - } - }, [updateTreeFileNode, refreshTreeFiles]); - - const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => { - updateTreeFileNode(fileId, { neutralize: newValue }); - try { - await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue }); - } catch (err) { - console.error('Failed to toggle neutralize:', err); - await refreshTreeFiles(); - } - }, [updateTreeFileNode, refreshTreeFiles]); - - const _onFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => { - try { - await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue }); - await refreshFolders(); - await refreshTreeFiles(); - } catch (err) { - console.error('Failed to toggle folder neutralize:', err); - } - }, [refreshFolders, refreshTreeFiles]); - - const _customActions: FileAction[] = useMemo(() => { + const _customActions = useMemo(() => { if (context.surface !== 'graphEditor') return []; return [ { id: 'workflow.openInEditor', - label: t('In Graph-Editor laden'), - icon: FaFileImport, - scope: 'file', - channels: ['inline', 'menu', 'sheet', 'drop'], - dragMime: 'application/json+workflow', - sortOrder: 50, - predicate: ({ files }) => - files.length === 1 && - files[0].fileName.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION), - handler: async ({ files }) => { - const file = files[0]; - if (!context.instanceId || !file) return; + icon: , + title: t('In Graph-Editor laden'), + onClick: async (row: any) => { + if (!context.instanceId || !row?.id) return; + if (!row.fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return; try { - const result = await importWorkflowFromFile(request, context.instanceId, { - fileId: file.id, - }); + const result = await importWorkflowFromFile(request, context.instanceId, { fileId: row.id }); const warnings = result?.warnings ?? []; const wfId = result?.workflow?.id; if (warnings.length > 0) { - showSuccess( - t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { - n: String(warnings.length), - }), - ); + showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) })); } else { showSuccess(t('Workflow importiert (deaktiviert).')); } @@ -231,24 +172,11 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat showError(t('Import fehlgeschlagen: {msg}', { msg })); } }, + hidden: (row: any) => !row?.fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION), }, ]; }, [context.surface, context.instanceId, t, request, showSuccess, showError, onWorkflowImported]); - const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { - try { - await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); - await refreshFolders(); - await refreshTreeFiles(); - } catch (err) { - console.error('Failed to change folder scope:', err); - } - }, [refreshFolders, refreshTreeFiles]); - - if (treeFilesLoading && treeFileNodes.length === 0) { - return
{t('Dateien laden')}
; - } - return (
= ({ context, onFileSelect, onSendToChat {uploading ? '...' : '+'} {canCreate && ( <> - -
+ {/* Sync-in-progress banner */} + {syncBanner && ( +
+ +
+ + {t('Wissensdatenbank wird synchronisiert')} + + + {t( + 'Inhalte aus {connector} werden im Hintergrund indexiert. Das kann je nach Datenmenge einige Minuten dauern. Die Wissensdatenbank steht danach vollständig zur Verfügung.', + { connector: syncBanner.connector }, + )} + +
+ +
+ )} +
{ handleDelete: deleteConnection, handleInlineUpdate, updateOptimistically, + groupTree, }} + groupingConfig={{ contextKey: 'connections', enabled: true }} emptyMessage={t('Keine Verbindungen gefunden')} />
@@ -623,6 +642,13 @@ export const ConnectionsPage: React.FC = () => { )} + + setWizardOpen(false)} + onConnect={handleWizardConnect} + isConnecting={isConnecting} + /> ); }; diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index e7c98d3..36c7802 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -1,33 +1,29 @@ /** * FilesPage * - * Split-view file management: FolderTree on the left, FormGeneratorTable on the right. - * The tree is the master – it dictates which folder's files the table shows (paginated). - * Tree files are managed by FileContext (lazy-loaded per expanded folder). + * Full-width file management using FormGeneratorTable with persistent grouping. + * Organisation exclusively via groupTree/groupId — no physical folder navigation. */ import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; -import api from '../../api'; import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; -import { useFileContext } from '../../contexts/FileContext'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; -import FolderTree from '../../components/FolderTree/FolderTree'; -import { useResizablePanels } from '../../hooks/useResizablePanels'; -import { FaSync, FaUpload, FaDownload, FaFolderPlus } from 'react-icons/fa'; +import { FaSync, FaUpload, FaDownload, FaLock, FaLockOpen, FaFileArchive, FaTrash } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; -import { usePrompt } from '../../hooks/usePrompt'; +import { useApiRequest } from '../../hooks/useApi'; +import { patchGroupScope, downloadGroupZip, deleteGroup } from '../../api/fileApi'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; import { getUserDataCache } from '../../utils/userCache'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +import type { GroupBulkAction } from '../../components/FormGenerator/GroupingManager/GroupRow'; interface UserFile { id: string; fileName: string; mimeType?: string; fileSize?: number; - folderId?: string | null; featureInstanceId?: string; [key: string]: any; } @@ -36,19 +32,9 @@ export const FilesPage: React.FC = () => { const { t } = useLanguage(); const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); - const { prompt: promptInput, PromptDialog } = usePrompt(); - const [selectedFolderId, setSelectedFolderId] = useState(null); + const { request } = useApiRequest(); - const { - leftWidth, isDragging, handleMouseDown, containerRef, - } = useResizablePanels({ - storageKey: 'filesPage-panelWidth', - defaultLeftWidth: 22, - minLeftWidth: 15, - maxLeftWidth: 40, - }); - - // ── Table data (paginated, filtered by selectedFolderId) ────────────── + // ── Table data ──────────────────────────────────────────────────────── const { data: tableFiles, attributes, @@ -57,6 +43,7 @@ export const FilesPage: React.FC = () => { error, refetch: tableRefetch, pagination, + groupTree, fetchFileById, updateFileOptimistically, } = useUserFiles(); @@ -74,127 +61,22 @@ export const FilesPage: React.FC = () => { previewingFiles, } = useFileOperations(); - // ── Tree data (from FileContext – lazy-loaded per expanded folder) ───── - const { - folders, - refreshFolders, - treeFileNodes, - refreshTreeFiles, - updateTreeFileNode, - expandedFolderIds, - toggleFolderExpanded, - handleCreateFolder, - handleRenameFolder, - handleDeleteFolder, - handleMoveFolder, - handleMoveFolders, - handleMoveFile: contextMoveFile, - handleMoveFiles: contextMoveFiles, - handleDownloadFolder, - } = useFileContext(); - const [editingFile, setEditingFile] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]); - const [treeSelectedIds, setTreeSelectedIds] = useState>(new Set()); - const [highlightedFileId, setHighlightedFileId] = useState(null); - // ── Table refetch: filter by real folderId ─────────────────────────── + // ── Table refetch wrapper ────────────────────────────────────────────── const _tableRefetch = useCallback(async (params?: any) => { - const nextParams = { ...(params || {}) }; - const nextFilters = { ...(nextParams.filters || {}) }; - - if (!selectedFolderId) { - nextFilters.folderId = null; - } else { - nextFilters.folderId = selectedFolderId; - } - - nextParams.filters = nextFilters; - await tableRefetch(nextParams); - }, [tableRefetch, selectedFolderId]); - - useEffect(() => { - _tableRefetch({ page: 1, pageSize: 25 }); - }, [selectedFolderId, _tableRefetch]); + await tableRefetch(params); + }, [tableRefetch]); const _refreshAll = useCallback(async () => { - await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); - }, [_tableRefetch, refreshTreeFiles, refreshFolders]); + await _tableRefetch({ page: 1, pageSize: 25 }); + }, [_tableRefetch]); - const _handleScopeChange = useCallback(async (fileId: string, newScope: string) => { - updateTreeFileNode(fileId, { scope: newScope }); - try { - await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); - _tableRefetch(); - } catch (err) { - console.error('Failed to update scope:', err); - await Promise.all([refreshTreeFiles(), _tableRefetch()]); - } - }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); - - const _handleNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => { - updateTreeFileNode(fileId, { neutralize: newValue }); - try { - await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue }); - _tableRefetch(); - } catch (err) { - console.error('Failed to toggle neutralize:', err); - await Promise.all([refreshTreeFiles(), _tableRefetch()]); - } - }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); - - const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { - try { - await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); - await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); - } catch (err) { - console.error('Failed to update folder scope:', err); - } - }, [refreshFolders, refreshTreeFiles, _tableRefetch]); - - const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => { - try { - await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue }); - await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); - } catch (err) { - console.error('Failed to toggle folder neutralize:', err); - } - }, [refreshFolders, refreshTreeFiles, _tableRefetch]); - - // ── Folder nodes for tree (real folders only) ──────────────────────── - const folderNodes = useMemo(() => { - return folders.map(f => ({ - id: f.id, - name: f.name, - parentId: f.parentId ?? null, - fileCount: f.fileCount ?? 0, - neutralize: f.neutralize ?? false, - scope: f.scope ?? 'personal', - })); - }, [folders]); - - const selectedFolderName = useMemo(() => { - if (!selectedFolderId) return null; - return folders.find(f => f.id === selectedFolderId)?.name ?? null; - }, [folders, selectedFolderId]); - - const emptyTableMessage = useMemo(() => { - if (!selectedFolderId) { - return t('Keine Dateien gefunden'); - } - return ( -
-
- {selectedFolderName - ? t('Der Ordner „{name}" ist leer.', { name: selectedFolderName }) - : t('Dieser Ordner ist leer.')} -
-
- {t('Lade eine neue Datei hoch oder verschiebe bestehende Dateien hierher.')} -
-
- ); - }, [selectedFolderId, selectedFolderName, t]); + // Initial fetch + useEffect(() => { + _tableRefetch({ page: 1, pageSize: 25 }); + }, [_tableRefetch]); // ── Columns ─────────────────────────────────────────────────────────── const columns = useMemo(() => { @@ -225,9 +107,6 @@ export const FilesPage: React.FC = () => { maxWidth: 250, displayField: 'sysCreatedByLabel', } as any); - // sysModifiedAt is marked frontend_visible=false in PowerOnModel so it - // never reaches us via the /api/attributes endpoint - declare type - // explicitly so the FormGenerator renders it as a timestamp. cols.push({ key: 'sysModifiedAt', label: t('Geaendert am'), @@ -249,50 +128,6 @@ export const FilesPage: React.FC = () => { const currentUserId = useMemo(() => getUserDataCache()?.id || '', []); const _isOwned = useCallback((row: UserFile) => row.sysCreatedBy === currentUserId, [currentUserId]); - // ── Tree event handlers ─────────────────────────────────────────────── - const _handleTreeFileSelect = useCallback((fileId: string) => { - const file = treeFileNodes.find(f => f.id === fileId); - if (file) { - setSelectedFolderId(file.folderId ?? null); - setHighlightedFileId(fileId); - requestAnimationFrame(() => { - const row = document.querySelector('tr[data-highlighted="true"]'); - if (row) row.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - setTimeout(() => setHighlightedFileId(null), 2500); - } - }, [treeFileNodes]); - - const _handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { - await contextMoveFile(fileId, targetFolderId); - await _tableRefetch(); - }, [contextMoveFile, _tableRefetch]); - - const _handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { - await contextMoveFiles(fileIds, targetFolderId); - await _tableRefetch(); - }, [contextMoveFiles, _tableRefetch]); - - const _handleRenameFile = useCallback(async (fileId: string, newName: string) => { - await handleFileUpdate(fileId, { fileName: newName }); - await Promise.all([_tableRefetch(), refreshTreeFiles()]); - }, [handleFileUpdate, _tableRefetch, refreshTreeFiles]); - - const _handleDeleteTreeFile = useCallback(async (fileId: string) => { - await handleFileDelete(fileId); - await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); - }, [handleFileDelete, _tableRefetch, refreshTreeFiles, refreshFolders]); - - const _handleDeleteTreeFiles = useCallback(async (fileIds: string[]) => { - await handleFileDeleteMultiple(fileIds); - await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); - }, [handleFileDeleteMultiple, _tableRefetch, refreshTreeFiles, refreshFolders]); - - const _handleDeleteTreeFolders = useCallback(async (folderIds: string[]) => { - await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); - await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); - }, [refreshFolders, refreshTreeFiles, _tableRefetch]); - // ── Table event handlers ────────────────────────────────────────────── const handleEditClick = async (file: UserFile) => { const fullFile = await fetchFileById(file.id); @@ -302,7 +137,7 @@ export const FilesPage: React.FC = () => { const handleEditSubmit = async (data: Partial) => { if (!editingFile) return; const changes: Record = {}; - const editableFields = ['fileName', 'scope', 'tags', 'description', 'folderId', 'neutralize'] as const; + const editableFields = ['fileName', 'scope', 'tags', 'description', 'neutralize'] as const; for (const field of editableFields) { if (data[field] !== undefined && data[field] !== editingFile[field]) { changes[field] = data[field]; @@ -314,19 +149,19 @@ export const FilesPage: React.FC = () => { const result = await handleFileUpdate(editingFile.id, changes); if (result.success) { setEditingFile(null); - await Promise.all([_tableRefetch(), refreshTreeFiles()]); + await _tableRefetch(); } }; const handleDelete = async (file: UserFile) => { const success = await handleFileDelete(file.id); - if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); + if (success) await _tableRefetch(); }; const handleDeleteMultiple = async (filesToDelete: UserFile[]) => { const ids = filesToDelete.map(f => f.id); const success = await handleFileDeleteMultiple(ids); - if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); + if (success) await _tableRefetch(); }; const handleDownload = async (file: UserFile) => { @@ -341,11 +176,11 @@ export const FilesPage: React.FC = () => { let successCount = 0; let errorCount = 0; for (const file of Array.from(picked)) { - const result = await handleFileUpload(file, undefined, undefined, selectedFolderId); + const result = await handleFileUpload(file); if (result?.success) successCount++; else errorCount++; } if (fileInputRef.current) fileInputRef.current.value = ''; - await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); + await _tableRefetch(); if (successCount > 0) { showSuccess( t('Upload erfolgreich'), @@ -359,12 +194,54 @@ export const FilesPage: React.FC = () => { } }; - const _handleNewFolder = useCallback(async () => { - const name = await promptInput(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') }); - if (name?.trim()) { - await handleCreateFolder(name.trim(), selectedFolderId); - } - }, [handleCreateFolder, selectedFolderId, promptInput, t]); + const _groupBulkActionsProvider = useCallback((groupId: string, itemIds: string[]): GroupBulkAction[] => { + return [ + { + icon: , + title: t('Scope: personal'), + onClick: async () => { + try { + await patchGroupScope(request, groupId, 'personal'); + showSuccess(t('Scope aktualisiert'), t('{n} Dateien auf personal gesetzt', { n: String(itemIds.length) })); + await _tableRefetch(); + } catch (e) { showError(t('Fehler'), String(e)); } + }, + }, + { + icon: , + title: t('Scope: mandate'), + onClick: async () => { + try { + await patchGroupScope(request, groupId, 'mandate'); + showSuccess(t('Scope aktualisiert'), t('{n} Dateien auf mandate gesetzt', { n: String(itemIds.length) })); + await _tableRefetch(); + } catch (e) { showError(t('Fehler'), String(e)); } + }, + }, + { + icon: , + title: t('ZIP herunterladen'), + onClick: async () => { + try { await downloadGroupZip(groupId); } + catch (e) { showError(t('Fehler'), String(e)); } + }, + disabled: itemIds.length === 0, + }, + { + icon: , + title: t('Gruppe + Dateien löschen'), + variant: 'danger' as const, + onClick: async () => { + try { + await deleteGroup(request, groupId, true); + showSuccess(t('Gelöscht'), t('Gruppe und {n} Dateien gelöscht', { n: String(itemIds.length) })); + await _tableRefetch(); + } catch (e) { showError(t('Fehler'), String(e)); } + }, + disabled: itemIds.length === 0, + }, + ]; + }, [request, showSuccess, showError, _tableRefetch, t]); const _onRowDragStart = useCallback((e: React.DragEvent, row: UserFile) => { const isInSelection = selectedFiles.some(f => f.id === row.id); @@ -373,11 +250,11 @@ export const FilesPage: React.FC = () => { } else { e.dataTransfer.setData('application/file-id', row.id); } - e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.effectAllowed = 'copyMove'; }, [selectedFiles]); const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source']; + const excludedFields = ['id', 'mandateId', 'fileHash', 'folderId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); @@ -411,155 +288,88 @@ export const FilesPage: React.FC = () => {

{t('Dateiverwaltung')}

-
-
} - style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }} - > - {/* Left panel: FolderTree */} +
- { - await handleDeleteFolder(folderId); - if (selectedFolderId === folderId) setSelectedFolderId(null); - await _tableRefetch(); - }} - onMoveFolder={handleMoveFolder} - onMoveFolders={handleMoveFolders} - onMoveFile={_handleMoveFile} - onMoveFiles={_handleMoveFiles} - onRenameFile={_handleRenameFile} - onDeleteFile={_handleDeleteTreeFile} - onDeleteFiles={_handleDeleteTreeFiles} - onDeleteFolders={_handleDeleteTreeFolders} - onDownloadFolder={handleDownloadFolder} - onScopeChange={_handleScopeChange} - onNeutralizeToggle={_handleNeutralizeToggle} - onFolderScopeChange={_handleFolderScopeChange} - onFolderNeutralizeToggle={_handleFolderNeutralizeToggle} - /> + {canCreate && ( + + )}
- {/* Resizable divider */} -
{ (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }} - onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }} - /> - - {/* Right panel: File table */} -
-
- - {canCreate && ( - - )} -
- -
- setSelectedFiles(rows as UserFile[])} - rowDraggable={true} - onRowDragStart={_onRowDragStart} - getRowDataAttributes={(row: UserFile) => - ({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) - } - actionButtons={[ - { - type: 'view' as const, - onAction: () => { /* ContentPreview fetches the file itself once the popup opens */ }, - title: t('Vorschau'), - idField: 'id', - nameField: 'fileName', - typeField: 'mimeType', - loadingStateName: 'previewingFiles', - }, - ...(canUpdate ? [{ - type: 'edit' as const, - onAction: handleEditClick, - title: t('Bearbeiten'), - disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann bearbeiten') } : false, - }] : []), - ...(canDelete ? [{ - type: 'delete' as const, - title: t('Löschen'), - loading: (row: UserFile) => deletingFiles.has(row.id), - disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann löschen') } : false, - }] : []), - ]} - customActions={[ - { - id: 'download', - icon: , - onClick: handleDownload, - title: t('Herunterladen'), - loading: (row: UserFile) => downloadingFiles.has(row.id), - }, - ]} - onDelete={handleDelete} - onDeleteMultiple={handleDeleteMultiple} - hookData={{ - refetch: _tableRefetch, - pagination, - permissions, - handleDelete: handleFileDelete, - handleInlineUpdate, - updateOptimistically: updateFileOptimistically, - previewingFiles, - }} - emptyMessage={emptyTableMessage} - /> -
+
+ setSelectedFiles(rows as UserFile[])} + rowDraggable={true} + onRowDragStart={_onRowDragStart} + actionButtons={[ + { + type: 'view' as const, + onAction: () => {}, + title: t('Vorschau'), + idField: 'id', + nameField: 'fileName', + typeField: 'mimeType', + loadingStateName: 'previewingFiles', + }, + ...(canUpdate ? [{ + type: 'edit' as const, + onAction: handleEditClick, + title: t('Bearbeiten'), + disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann bearbeiten') } : false, + }] : []), + ...(canDelete ? [{ + type: 'delete' as const, + title: t('Löschen'), + loading: (row: UserFile) => deletingFiles.has(row.id), + disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann löschen') } : false, + }] : []), + ]} + customActions={[ + { + id: 'download', + icon: , + onClick: handleDownload, + title: t('Herunterladen'), + loading: (row: UserFile) => downloadingFiles.has(row.id), + }, + ]} + onDelete={handleDelete} + onDeleteMultiple={handleDeleteMultiple} + hookData={{ + refetch: _tableRefetch, + pagination, + permissions, + handleDelete: handleFileDelete, + handleInlineUpdate, + updateOptimistically: updateFileOptimistically, + previewingFiles, + groupTree, + }} + groupingConfig={{ contextKey: 'files/list', enabled: true }} + groupBulkActionsProvider={_groupBulkActionsProvider} + emptyMessage={t('Keine Dateien gefunden')} + />
@@ -591,7 +401,6 @@ export const FilesPage: React.FC = () => {
)} -
); }; diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index 3fa1bdf..86eba34 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -34,6 +34,7 @@ export const PromptsPage: React.FC = () => { loading, error, refetch, + groupTree, fetchPromptById, updateOptimistically, } = usePrompts(); @@ -236,7 +237,9 @@ export const PromptsPage: React.FC = () => { handleDelete: handlePromptDelete, handleInlineUpdate, updateOptimistically, + groupTree, }} + groupingConfig={{ contextKey: 'prompts', enabled: true }} emptyMessage={t('Keine Prompts gefunden')} /> diff --git a/src/pages/views/neutralization/NeutralizationView.tsx b/src/pages/views/neutralization/NeutralizationView.tsx index 2ee5845..aef08ca 100644 --- a/src/pages/views/neutralization/NeutralizationView.tsx +++ b/src/pages/views/neutralization/NeutralizationView.tsx @@ -9,7 +9,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useToast } from '../../../contexts/ToastContext'; -import { useFileContext } from '../../../contexts/FileContext'; +import { useFileOperations, useUserFiles } from '../../../hooks/useFiles'; import { useConnections, type Connection } from '../../../hooks/useConnections'; import { getNeutralizationConfig, @@ -178,7 +178,8 @@ const ConfigTab: React.FC = () => { const PlaygroundTab: React.FC = () => { const { t } = useLanguage(); const { showSuccess, showError } = useToast(); - const { refreshTreeFiles: refetchFiles, handleFileDownload } = useFileContext(); + const { handleFileDownload } = useFileOperations(); + const { refetch: refetchFiles } = useUserFiles(); const { connections } = useConnections(); const msftConnections = connections.filter( diff --git a/src/pages/views/teamsbot/TeamsbotSessionView.tsx b/src/pages/views/teamsbot/TeamsbotSessionView.tsx index 72970e2..8d8b7fd 100644 --- a/src/pages/views/teamsbot/TeamsbotSessionView.tsx +++ b/src/pages/views/teamsbot/TeamsbotSessionView.tsx @@ -418,6 +418,8 @@ export const TeamsbotSessionView: React.FC = () => { e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/file-id') || e.dataTransfer.types.includes('application/file-ids') || + e.dataTransfer.types.includes('application/group-file-ids') || + e.dataTransfer.types.includes('application/group-id') || e.dataTransfer.types.includes('application/tree-items') ) { e.preventDefault(); @@ -432,6 +434,8 @@ export const TeamsbotSessionView: React.FC = () => { e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('application/file-id') || e.dataTransfer.types.includes('application/file-ids') || + e.dataTransfer.types.includes('application/group-file-ids') || + e.dataTransfer.types.includes('application/group-id') || e.dataTransfer.types.includes('application/tree-items') ) { e.preventDefault(); @@ -453,6 +457,17 @@ export const TeamsbotSessionView: React.FC = () => { directorDragCounterRef.current = 0; setDirectorDragOver(false); + const groupFileIdsJson = e.dataTransfer.getData('application/group-file-ids'); + if (groupFileIdsJson) { + try { + const ids: unknown = JSON.parse(groupFileIdsJson); + if (Array.isArray(ids) && ids.length > 0) { + ids.forEach((id) => typeof id === 'string' && id && _addDirectorFile(id)); + return; + } + } catch { /* ignore malformed */ } + } + const fileIdsJson = e.dataTransfer.getData('application/file-ids'); if (fileIdsJson) { try { @@ -469,10 +484,16 @@ export const TeamsbotSessionView: React.FC = () => { return; } + const groupId = e.dataTransfer.getData('application/group-id'); + if (groupId) { + _addDirectorFile(groupId); + return; + } + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson) { try { - const items: Array<{ id: string; type: 'file' | 'folder'; name: string }> = JSON.parse(treeItemsJson); + const items: Array<{ id: string; type: 'file' | 'group'; name: string }> = JSON.parse(treeItemsJson); items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name)); } catch { /* ignore malformed */ } return; diff --git a/src/pages/views/workspace/ToolActivityLog.tsx b/src/pages/views/workspace/ToolActivityLog.tsx index 6986084..8318237 100644 --- a/src/pages/views/workspace/ToolActivityLog.tsx +++ b/src/pages/views/workspace/ToolActivityLog.tsx @@ -104,9 +104,11 @@ export const ToolActivityLog: React.FC = ({ activities }) case 'connectNodes': return t('Knoten verbinden'); case 'copyFile': return t('Datei kopieren'); case 'createChart': return t('Diagramm erstellen'); + case 'createGroup': return t('Gruppe anlegen'); case 'createFolder': return t('Ordner anlegen'); case 'createRecord': return t('Datensatz erstellen'); case 'deleteFile': return t('Datei löschen'); + case 'deleteGroup': return t('Gruppe löschen'); case 'deleteFolder': return t('Ordner löschen'); case 'deleteRecord': return t('Datensatz löschen'); case 'describeImage': return t('Bild beschreiben'); @@ -123,10 +125,18 @@ export const ToolActivityLog: React.FC = ({ activities }) case 'listAvailableNodeTypes': return t('Verfügbare Knotentypen auflisten'); case 'listConnections': return t('Verbindungen auflisten'); case 'listFiles': return t('Dateien auflisten'); + case 'listGroups': return t('Gruppen auflisten'); + case 'listItemsInGroup': return t('Gruppeninhalt auflisten'); + case 'addItemsToGroup': return t('Zu Gruppe hinzufügen'); + case 'moveItemsBetweenGroups': return t('Zwischen Gruppen verschieben'); + case 'ensureInstanceGroup': return t('Instanzgruppe sicherstellen'); + case 'ensureTempGroup': return t('Temp-Gruppe sicherstellen'); case 'listFolders': return t('Ordner auflisten'); case 'listTables': return t('Tabellen auflisten'); case 'listWorkflowHistory': return t('Workflow-Verlauf'); case 'moveFile': return t('Datei verschieben'); + case 'moveGroup': return t('Gruppe verschieben'); + case 'renameGroup': return t('Gruppe umbenennen'); case 'moveFolder': return t('Ordner verschieben'); case 'neutralizeData': return t('Daten neutralisieren'); case 'outlook_composeAndDraftReply': return t('Outlook-Antwort entwerfen'); diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index cb70c99..0c095f7 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -3,7 +3,14 @@ * voice toggle (generic audio capture hook), and data source selection. */ -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { + useState, + useCallback, + useRef, + useEffect, + useImperativeHandle, + forwardRef, +} from 'react'; import { ProviderMultiSelect } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector'; import { getPageIcon } from '../../../config/pageRegistry'; @@ -14,16 +21,25 @@ import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspac import { useLanguage } from '../../../providers/language/LanguageContext'; import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext'; -interface PendingFile { - fileId: string; - fileName: string; - itemType?: 'file' | 'folder'; +export interface TreeItemDrop { + id: string; + type: 'file' | 'group'; + name: string; } -interface TreeItemDrop { - id: string; - type: 'file' | 'folder'; - name: string; +/** An attachment chip shown in the input bar. + * Groups are kept as-is (show as single chip); file IDs are resolved at send-time. */ +export type AttachmentItem = + | { type: 'file'; id: string; name: string } + | { type: 'group'; id: string; name: string; fileIds: string[] }; + +/** Parent resolves groups to concrete file IDs using persisted group tree. */ +export type ResolveTreeItemsToFileIds = (items: TreeItemDrop[]) => Promise; + +export interface WorkspaceInputHandle { + attachFileIds: (ids: string[]) => void; + attachTreeItems: (items: TreeItemDrop[]) => Promise; + ingestTreeDataTransfer: (dt: DataTransfer) => Promise; } interface WorkspaceInputProps { @@ -34,14 +50,12 @@ interface WorkspaceInputProps { files: WorkspaceFile[]; dataSources: DataSource[]; featureDataSources?: FeatureDataSource[]; - pendingFiles?: PendingFile[]; - onRemovePendingFile?: (fileId: string) => void; + resolveTreeItemsToFileIds: ResolveTreeItemsToFileIds; onFileUploadClick?: () => void; uploading?: boolean; providerSelection?: ProviderSelection; onProviderSelectionChange?: (selection: ProviderSelection) => void; isMobile?: boolean; - onTreeItemsDrop?: (items: TreeItemDrop[]) => void; onFeatureSourceDrop?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void; pendingAttachDsId?: string; @@ -51,36 +65,62 @@ interface WorkspaceInputProps { onPasteAsFile?: (file: File) => void; draftAppend?: string; onDraftAppendConsumed?: () => void; - /** - * Per-chat attachment persistence. When the parent loads a workflow, it - * passes the IDs the backend has stored for that chat plus a nonce that - * increments on every load. The chip-bar is then rehydrated, dropping - * any IDs that no longer resolve against the available sources. - * - * `workflowId` is needed so that "x" detachments can be persisted via a - * PATCH call without waiting for the next sendMessage round-trip. - */ workflowId?: string | null; loadedAttachedDataSourceIds?: string[]; loadedAttachedFeatureDataSourceIds?: string[]; loadedNonce?: number; } -export const WorkspaceInput: React.FC = ({ instanceId, +function _itemsFromTreeDataTransfer(dt: DataTransfer): TreeItemDrop[] | null { + const groupId = dt.getData('application/group-id'); + if (groupId) { + return [{ id: groupId, type: 'group', name: dt.getData('text/plain') || groupId }]; + } + const portaG = dt.getData('application/porta-group'); + if (portaG) { + return [{ id: portaG, type: 'group', name: dt.getData('text/plain') || portaG }]; + } + const treeItemsJson = dt.getData('application/tree-items'); + if (treeItemsJson) { + try { + const items = JSON.parse(treeItemsJson) as TreeItemDrop[]; + return Array.isArray(items) && items.length ? items : null; + } catch { + return null; + } + } + const fileIdsJson = dt.getData('application/file-ids'); + if (fileIdsJson) { + try { + const ids: string[] = JSON.parse(fileIdsJson); + return ids.map(id => ({ id, type: 'file' as const, name: id })); + } catch { + return null; + } + } + const singleFileId = dt.getData('application/file-id'); + if (singleFileId) { + const lbl = dt.getData('text/plain'); + const name = lbl && lbl !== singleFileId ? lbl : singleFileId; + return [{ id: singleFileId, type: 'file', name }]; + } + return null; +} + +export const WorkspaceInput = forwardRef(function WorkspaceInput({ + instanceId, onSend, isProcessing, onStop, files, dataSources, featureDataSources = [], - pendingFiles = [], - onRemovePendingFile, + resolveTreeItemsToFileIds, onFileUploadClick, uploading = false, providerSelection, onProviderSelectionChange, isMobile = false, - onTreeItemsDrop, onFeatureSourceDrop, onDataSourceDrop, pendingAttachDsId, @@ -94,23 +134,37 @@ export const WorkspaceInput: React.FC = ({ instanceId, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds, loadedNonce, -}) => { +}, ref) { const { t } = useLanguage(); const { languages: voiceCatalogLanguages } = useVoiceCatalog(); const [prompt, setPrompt] = useState(''); const [showAutocomplete, setShowAutocomplete] = useState(false); const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [treeDropOver, setTreeDropOver] = useState(false); + const textareaAreaDragDepth = useRef(0); const [voiceActive, setVoiceActive] = useState(false); const [voiceLanguage, setVoiceLanguage] = useState('de-DE'); const [showLangPicker, setShowLangPicker] = useState(false); const _sttPrefsLoaded = useRef(false); - const [attachedFileIds, setAttachedFileIds] = useState([]); + const [attachments, setAttachments] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); const [neutralizeActive, setNeutralizeActive] = useState(false); const textareaRef = useRef(null); + const _appendAttachment = useCallback((item: AttachmentItem) => { + setAttachments(prev => prev.some(a => a.id === item.id) ? prev : [...prev, item]); + }, []); + + const _appendFileIds = useCallback((ids: string[]) => { + if (!ids.length) return; + setAttachments(prev => { + const existing = new Set(prev.map(a => a.id)); + const added = ids.filter(id => !existing.has(id)).map(id => ({ type: 'file' as const, id, name: id })); + return added.length ? [...prev, ...added] : prev; + }); + }, []); + useEffect(() => { if (draftAppend) { setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend); @@ -118,10 +172,6 @@ export const WorkspaceInput: React.FC = ({ instanceId, } }, [draftAppend, onDraftAppendConsumed]); - // Persist a changed attachment list to the backend so the next chat - // reload reflects the current state. Defined early so the - // pendingAttachDsId / pendingAttachFdsId effects below can also persist - // immediately after a 💬-click or drag-drop attach. const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => { if (!instanceId || !workflowId) return; api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, { @@ -130,10 +180,6 @@ export const WorkspaceInput: React.FC = ({ instanceId, }).catch(err => console.warn('Failed to persist chat attachments:', err)); }, [instanceId, workflowId]); - // 💬-click or drag-drop attach: parent sets pendingAttachDsId after - // creating/finding the DataSource. Add to the chip bar AND persist - // immediately so a chat reload before the user sends a message still - // shows the chip. useEffect(() => { if (!pendingAttachDsId) return; setAttachedDataSourceIds(prev => { @@ -156,34 +202,20 @@ export const WorkspaceInput: React.FC = ({ instanceId, onPendingAttachFdsConsumed?.(); }, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]); - // Rehydrate the chip-bar whenever the parent re-loads a chat (loadedNonce - // bumps on every loadWorkflow call). We trust the loaded IDs initially; - // a separate one-shot reconciliation below drops IDs that don't resolve - // once the source lists have arrived from the backend. useEffect(() => { if (loadedNonce === undefined) return; - setAttachedFileIds([]); + setAttachments([]); setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []); setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []); - }, [loadedNonce]); + }, [loadedNonce, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds]); - // Drop persisted attachment IDs that no longer resolve to an existing - // source (e.g. the DataSource was deleted while the chat was closed). - // - // CRITICAL: this MUST run only once per chat-load (per `loadedNonce`), - // and only after the source lists have actually arrived. A continuous - // filter would race with `_handleDataSourceDrop` / - // `_handleSendToChat_FeatureSource` in the parent: the drop sets the - // chip via `pendingAttachDsId` *before* `refreshDataSources()` has - // returned, so a continuous filter would briefly evict the freshly - // dropped ID and the chip would visibly flash in and out. const _reconciledDsForNonce = useRef(undefined); const _reconciledFdsForNonce = useRef(undefined); useEffect(() => { if (loadedNonce === undefined) return; if (_reconciledDsForNonce.current === loadedNonce) return; - if (dataSources.length === 0) return; // wait for the list to arrive + if (dataSources.length === 0) return; _reconciledDsForNonce.current = loadedNonce; const validIds = new Set(dataSources.map(d => d.id)); setAttachedDataSourceIds(prev => { @@ -217,9 +249,61 @@ export const WorkspaceInput: React.FC = ({ instanceId, .catch(() => {}); }, []); + const _resolveGroupItem = useCallback(async (item: TreeItemDrop): Promise => { + const fileIds = await resolveTreeItemsToFileIds([item]); + return { type: 'group', id: item.id, name: item.name, fileIds }; + }, [resolveTreeItemsToFileIds]); + + /** Ingest a DataTransfer and append the right attachment chips. Returns true if handled. */ + const _ingestDataTransfer = useCallback(async (dt: DataTransfer): Promise => { + // Group with drag-time snapshot of its file IDs + const groupId = dt.getData('application/group-id') || dt.getData('application/porta-group'); + if (groupId) { + const name = dt.getData('text/plain') || groupId; + const snapshotJson = dt.getData('application/group-file-ids'); + let fileIds: string[] = []; + if (snapshotJson) { + try { + const parsed: unknown = JSON.parse(snapshotJson); + if (Array.isArray(parsed)) fileIds = parsed.filter((f): f is string => typeof f === 'string'); + } catch { /* ignore */ } + } + if (!fileIds.length) { + fileIds = await resolveTreeItemsToFileIds([{ id: groupId, type: 'group', name }]); + } + _appendAttachment({ type: 'group', id: groupId, name, fileIds }); + return true; + } + // Generic tree-items (may contain groups or files) + const items = _itemsFromTreeDataTransfer(dt); + if (!items?.length) return false; + await Promise.all(items.map(async item => { + if (item.type === 'group') { + _appendAttachment(await _resolveGroupItem(item)); + } else { + _appendAttachment({ type: 'file', id: item.id, name: item.name }); + } + })); + return true; + }, [resolveTreeItemsToFileIds, _appendAttachment, _resolveGroupItem]); + + useImperativeHandle(ref, () => ({ + attachFileIds: (ids: string[]) => _appendFileIds(ids), + attachTreeItems: async (items: TreeItemDrop[]) => { + await Promise.all(items.map(async item => { + if (item.type === 'group') { + _appendAttachment(await _resolveGroupItem(item)); + } else { + _appendAttachment({ type: 'file', id: item.id, name: item.name }); + } + })); + }, + ingestTreeDataTransfer: (dt: DataTransfer) => _ingestDataTransfer(dt), + }), [_appendFileIds, _appendAttachment, _resolveGroupItem, _ingestDataTransfer]); + const _extractFileRefs = useCallback( (text: string): string[] => { - const pattern = /@([\w.\-]+)/g; + const pattern = /@([\w.-]+)/g; const matched: string[] = []; let match; while ((match = pattern.exec(text)) !== null) { @@ -236,17 +320,21 @@ export const WorkspaceInput: React.FC = ({ instanceId, [files], ); + const hasFileOrSourceAttachments = + attachments.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0; + const _canSend = Boolean(prompt.trim()) || attachments.length > 0; + const _handleSend = useCallback(() => { - const trimmed = prompt.trim(); - if (!trimmed || isProcessing) return; - const inlineFileIds = _extractFileRefs(trimmed); + if ((!prompt.trim() && attachments.length === 0) || isProcessing) return; + const inlineFileIds = _extractFileRefs(prompt); + const attachedFileIds = attachments.flatMap(a => a.type === 'file' ? [a.id] : a.fileIds); const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; const options = neutralizeActive ? { requireNeutralization: true } : undefined; - onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options); + onSend(prompt.trim(), allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options); setPrompt(''); setShowAutocomplete(false); - setAttachedFileIds([]); - }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]); + setAttachments([]); + }, [prompt, isProcessing, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]); const _handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -264,7 +352,7 @@ export const WorkspaceInput: React.FC = ({ instanceId, setPrompt(value); const cursorPos = e.target.selectionStart; const textBeforeCursor = value.slice(0, cursorPos); - const atMatch = textBeforeCursor.match(/@([\w.\-]*)$/); + const atMatch = textBeforeCursor.match(/@([\w.-]*)$/); if (atMatch) { setAutocompleteFilter(atMatch[1].toLowerCase()); setShowAutocomplete(true); @@ -291,8 +379,8 @@ export const WorkspaceInput: React.FC = ({ instanceId, [prompt], ); - const _removeAttachedFile = useCallback((fileId: string) => { - setAttachedFileIds(prev => prev.filter(id => id !== fileId)); + const _removeAttachment = useCallback((id: string) => { + setAttachments(prev => prev.filter(a => a.id !== id)); }, []); const _removeAttachedDataSource = useCallback((dsId: string) => { @@ -370,7 +458,7 @@ export const WorkspaceInput: React.FC = ({ instanceId, ? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) : []; - const hasAttachments = attachedFileIds.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0; + const hasAttachments = hasFileOrSourceAttachments; const _horizontalPadding = isMobile ? 12 : 24; const _controlSize = isMobile ? 38 : 40; @@ -385,9 +473,21 @@ export const WorkspaceInput: React.FC = ({ instanceId, } }, [onPasteAsFile]); + const _isTreeMimeDrag = useCallback((e: React.DragEvent) => { + const types = e.dataTransfer.types; + return ( + types.includes('application/tree-items') || + types.includes('application/group-file-ids') || + types.includes('application/group-id') || + types.includes('application/porta-group') || + types.includes('application/file-id') || + types.includes('application/file-ids') + ); + }, []); + const _handlePromptDragOver = useCallback((e: React.DragEvent) => { if ( - e.dataTransfer.types.includes('application/tree-items') || + _isTreeMimeDrag(e) || e.dataTransfer.types.includes('application/chat-id') || e.dataTransfer.types.includes('application/feature-source') || e.dataTransfer.types.includes('application/datasource') @@ -396,11 +496,39 @@ export const WorkspaceInput: React.FC = ({ instanceId, e.dataTransfer.dropEffect = 'copy'; setTreeDropOver(true); } + }, [_isTreeMimeDrag]); + + const _handlePromptDragLeave = useCallback((e: React.DragEvent) => { + if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) { + setTreeDropOver(false); + } }, []); - const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []); + const _handleTextareaDragEnter = useCallback((e: React.DragEvent) => { + if (!_isTreeMimeDrag(e)) return; + e.preventDefault(); + textareaAreaDragDepth.current += 1; + setTreeDropOver(true); + }, [_isTreeMimeDrag]); - const _handlePromptDrop = useCallback((e: React.DragEvent) => { + const _handleTextareaDragLeave = useCallback((e: React.DragEvent) => { + if (!_isTreeMimeDrag(e)) return; + e.preventDefault(); + textareaAreaDragDepth.current = Math.max(0, textareaAreaDragDepth.current - 1); + if (textareaAreaDragDepth.current === 0) { + setTreeDropOver(false); + } + }, [_isTreeMimeDrag]); + + const _handleTextareaDragOver = useCallback((e: React.DragEvent) => { + if (!_isTreeMimeDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + }, [_isTreeMimeDrag]); + + const _handlePromptDrop = useCallback(async (e: React.DragEvent) => { + textareaAreaDragDepth.current = 0; setTreeDropOver(false); const chatId = e.dataTransfer.getData('application/chat-id'); @@ -408,8 +536,8 @@ export const WorkspaceInput: React.FC = ({ instanceId, e.preventDefault(); e.stopPropagation(); const chatLabel = e.dataTransfer.getData('text/plain'); - const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`; - setPrompt(prev => (prev ? `${prev} ${ref}` : ref)); + const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`; + setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel)); return; } @@ -431,14 +559,13 @@ export const WorkspaceInput: React.FC = ({ instanceId, return; } - const treeItemsJson = e.dataTransfer.getData('application/tree-items'); - if (treeItemsJson && onTreeItemsDrop) { + const handled = await _ingestDataTransfer(e.dataTransfer); + if (handled) { e.preventDefault(); e.stopPropagation(); - const items: TreeItemDrop[] = JSON.parse(treeItemsJson); - onTreeItemsDrop(items); + textareaRef.current?.focus(); } - }, [onTreeItemsDrop, onFeatureSourceDrop, onDataSourceDrop]); + }, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]); return (
= ({ instanceId, }} onDragOver={_handlePromptDragOver} onDragLeave={_handlePromptDragLeave} - onDrop={_handlePromptDrop} + onDrop={e => void _handlePromptDrop(e)} > - {/* Pending uploaded files */} - {pendingFiles.length > 0 && ( -
- {pendingFiles.map(pf => ( - - {pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName} - {onRemovePendingFile && ( - - )} - - ))} -
- )} - - {/* Attachment bar */} {hasAttachments && (
- {attachedFileIds.map(fId => { - const file = files.find(f => f.id === fId); + {attachments.map(att => { + const isGroup = att.type === 'group'; + const label = isGroup + ? att.name + : (files.find(f => f.id === att.id)?.fileName || att.name || att.id); + const chipBg = isGroup ? '#e8f5e9' : '#e3f2fd'; + const chipColor = isGroup ? '#1b5e20' : '#1565c0'; + const chipBorder = isGroup ? '1px solid #c8e6c9' : '1px solid #bbdefb'; + const countBadge = isGroup ? ` (${att.fileIds.length})` : ''; return ( - 📄 {file?.fileName || fId} + {isGroup ? '📁' : '📄'} {label}{countBadge}
)} - {/* Autocomplete dropdown */} {showAutocomplete && filteredFiles.length > 0 && (
= ({ instanceId, {filteredFiles.slice(0, 10).map(f => (
_insertFileRef(f.fileName)} style={{ padding: '8px 12px', @@ -617,7 +716,6 @@ export const WorkspaceInput: React.FC = ({ instanceId,
)} - {/* Main input row */}
= ({ instanceId, onChange={_handleChange} onKeyDown={_handleKeyDown} onPaste={_handlePaste} - placeholder={t('Geben Sie eine Nachricht ein, verwenden Sie @file für Dateien')} + onDragEnter={_handleTextareaDragEnter} + onDragLeave={_handleTextareaDragLeave} + onDragOver={_handleTextareaDragOver} + onDrop={e => void _handlePromptDrop(e)} + placeholder={ + attachments.length > 0 + ? t('Nachricht eingeben … ({n} Anhang/Anhänge)', { n: String(attachments.length) }) + : t('Geben Sie eine Nachricht ein — Dateien hierher ziehen oder @file verwenden') + } disabled={isProcessing} style={{ flex: 1, - minHeight: isMobile ? 44 : 40, + minHeight: isMobile ? 52 : 48, maxHeight: 120, resize: 'vertical', padding: '10px 14px', borderRadius: 8, - border: '1px solid var(--border-color, #ccc)', + border: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : '1px solid var(--border-color, #ccc)', fontSize: 14, fontFamily: 'inherit', outline: 'none', flexBasis: isMobile ? '100%' : undefined, + boxSizing: 'border-box', }} rows={1} /> - {/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */} - {onProviderSelectionChange && providerSelection && ( = ({ instanceId,
) : (
); -}; +}); diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index b94d96d..05f526a 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -14,11 +14,14 @@ import { useFileOperations } from '../../../hooks/useFiles'; import { useWorkspace } from './useWorkspace'; import { ChatStream } from './ChatStream'; import { WorkspaceInput } from './WorkspaceInput'; +import type { WorkspaceInputHandle, TreeItemDrop } from './WorkspaceInput'; import { FilePreview } from './FilePreview'; import { ToolActivityLog } from './ToolActivityLog'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar'; import api from '../../../api'; +import { collectGroupItemIds } from '../../../api/fileApi'; +import type { TableGroupNode } from '../../../api/connectionApi'; import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector'; import { useBilling } from '../../../hooks/useBilling'; @@ -58,12 +61,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) } type RightTab = 'activity' | 'preview'; -interface PendingFile { - fileId: string; - fileName: string; - itemType?: 'file' | 'folder'; -} - interface WorkspacePageProps { persistentInstanceId?: string; } @@ -85,7 +82,9 @@ export const WorkspacePage: React.FC = ({ persistentInstance const [rightTab, setRightTab] = useState('activity'); const [udbTab, setUdbTab] = useState('chats'); const [selectedFileId, setSelectedFileId] = useState(null); - const [pendingFiles, setPendingFiles] = useState([]); + const workspaceInputRef = useRef(null); + /** Persisted grouping tree from /api/files/list — resolves dropped groups → file IDs */ + const [filesListGroupTree, setFilesListGroupTree] = useState([]); const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); const { allowedProviders } = useBilling(); const [isDragOver, setIsDragOver] = useState(false); @@ -116,6 +115,27 @@ export const WorkspacePage: React.FC = ({ persistentInstance } }, [isMobile]); + const _pullFilesGroupTree = useCallback(async (): Promise => { + if (!instanceId) return []; + try { + const res = await api.get<{ groupTree?: TableGroupNode[] }>('/api/files/list', { + params: { page: 1, pageSize: 1 }, + }); + const gt = res.data?.groupTree; + const list = Array.isArray(gt) ? gt : []; + setFilesListGroupTree(list); + return list; + } catch { + setFilesListGroupTree([]); + return []; + } + }, [instanceId]); + + useEffect(() => { + _pullFilesGroupTree(); + }, [_pullFilesGroupTree]); + + useEffect(() => { if (autoStartHandled.current || !instanceId || workspace.isProcessing) return; const prompt = searchParams.get('prompt'); @@ -132,23 +152,77 @@ export const WorkspacePage: React.FC = ({ persistentInstance } }, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]); + const _resolveTreeItemsToFileIds = useCallback(async (items: TreeItemDrop[]) => { + let tree = filesListGroupTree; + if (items.some(i => i.type === 'group')) { + tree = await _pullFilesGroupTree(); + } + const out: string[] = []; + for (const it of items) { + if (it.type === 'group') { + out.push(...collectGroupItemIds(tree, it.id)); + } else { + out.push(it.id); + } + } + return [...new Set(out)]; + }, [filesListGroupTree, _pullFilesGroupTree]); + const _uploadAndAttach = useCallback(async (file: File) => { const result = await fileOps.handleFileUpload(file, undefined, instanceId); if (result.success && result.fileData) { const data = result.fileData.file || result.fileData; if (data?.id) { - setPendingFiles(prev => [...prev, { fileId: data.id, fileName: data.fileName || file.name }]); + workspaceInputRef.current?.attachFileIds([data.id]); } workspace.refreshFiles(); } }, [fileOps, workspace, instanceId]); + const _consumeDataTransferFilesOrChat = useCallback(async (dt: React.DragEvent['dataTransfer']) => { + const chatId = dt.getData('application/chat-id'); + if (chatId) { + try { + const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId }); + const body = res.data ?? {}; + if (body.summary) setDraftAppend(body.summary); + } catch (err) { + console.error('RAG resolve failed for dropped chat:', err); + } + return true; + } + if (workspaceInputRef.current && (await workspaceInputRef.current.ingestTreeDataTransfer(dt))) { + return true; + } + if (dt.files && dt.files.length > 0) { + for (const file of Array.from(dt.files)) { + await _uploadAndAttach(file); + } + return true; + } + return false; + }, [_uploadAndAttach, instanceId]); + + const _isCenterDropInteresting = useCallback((e: React.DragEvent) => { + const types = e.dataTransfer.types; + return ( + types.includes('application/tree-items') || + types.includes('application/group-file-ids') || + types.includes('application/group-id') || + types.includes('application/porta-group') || + types.includes('application/file-id') || + types.includes('application/file-ids') || + types.includes('application/chat-id') || + types.includes('Files') + ); + }, []); + const _handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounterRef.current++; - if (e.dataTransfer.types.includes('Files')) setIsDragOver(true); - }, []); + if (_isCenterDropInteresting(e)) setIsDragOver(true); + }, [_isCenterDropInteresting]); const _handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -158,9 +232,11 @@ export const WorkspacePage: React.FC = ({ persistentInstance }, []); const _handleDragOver = useCallback((e: React.DragEvent) => { + if (!_isCenterDropInteresting(e)) return; e.preventDefault(); e.stopPropagation(); - }, []); + e.dataTransfer.dropEffect = 'copy'; + }, [_isCenterDropInteresting]); const _handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); @@ -168,27 +244,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance dragCounterRef.current = 0; setIsDragOver(false); - const chatId = e.dataTransfer.getData('application/chat-id'); - if (chatId) { - try { - const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId }); - const body = res.data ?? {}; - if (body.summary) { - setDraftAppend(body.summary); - } - } catch (err) { - console.error('RAG resolve failed for dropped chat:', err); - } - return; - } - - const droppedFiles = e.dataTransfer.files; - if (droppedFiles.length > 0) { - for (const file of Array.from(droppedFiles)) { - await _uploadAndAttach(file); - } - } - }, [_uploadAndAttach, instanceId, workspace]); + await _consumeDataTransferFilesOrChat(e.dataTransfer); + }, [_consumeDataTransferFilesOrChat]); const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { @@ -197,22 +254,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance } }, [_uploadAndAttach]); - const _handleRemovePendingFile = useCallback((fileId: string) => { - setPendingFiles(prev => prev.filter(f => f.fileId !== fileId)); - }, []); - - const _handleTreeItemsDrop = useCallback((items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => { - setPendingFiles(prev => { - const existing = new Set(prev.map(f => f.fileId)); - const toAdd: PendingFile[] = []; - for (const item of items) { - if (!existing.has(item.id)) { - toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type }); - existing.add(item.id); - } - } - return [...prev, ...toAdd]; - }); + const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => { + void workspaceInputRef.current?.attachTreeItems( + items.map(i => ({ id: i.id, type: i.type, name: i.name })), + ); }, []); if (!instanceId) { @@ -279,20 +324,6 @@ export const WorkspacePage: React.FC = ({ persistentInstance workspace.refreshFeatureDataSources(); }, [workspace]); - const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => { - setPendingFiles(prev => { - const existing = new Set(prev.map(f => f.fileId)); - const toAdd: PendingFile[] = []; - for (const item of items) { - if (!existing.has(item.id)) { - toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type }); - existing.add(item.id); - } - } - return [...prev, ...toAdd]; - }); - }, []); - const [pendingAttachFdsId, setPendingAttachFdsId] = useState(''); const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => { @@ -497,7 +528,7 @@ export const WorkspacePage: React.FC = ({ persistentInstance fontSize: 16, fontWeight: 600, color: 'var(--primary-color, #F25843)', pointerEvents: 'none', }}> - Dateien hier ablegen + {t('Dateien oder Gruppen hier ablegen')}
)} = ({ persistentInstance onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)} /> { - const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])]; const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders); - workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options); - setPendingFiles([]); + workspace.sendMessage(prompt, fileIds || [], dataSourceIds, resolvedProviders, featureDataSourceIds, options); }} isProcessing={workspace.isProcessing} onStop={workspace.stopProcessing} files={workspace.files} dataSources={workspace.dataSources} featureDataSources={workspace.featureDataSources} - pendingFiles={pendingFiles} - onRemovePendingFile={_handleRemovePendingFile} + resolveTreeItemsToFileIds={_resolveTreeItemsToFileIds} onFileUploadClick={() => fileInputRef.current?.click()} uploading={fileOps.uploadingFile} providerSelection={providerSelection} onProviderSelectionChange={setProviderSelection} isMobile={isMobile} - onTreeItemsDrop={_handleTreeItemsDrop} onFeatureSourceDrop={_handleSendToChat_FeatureSource} onDataSourceDrop={_handleDataSourceDrop} pendingAttachDsId={pendingAttachDsId} diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index f79fd54..c2d35b2 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -35,7 +35,6 @@ export interface WorkspaceFile { mimeType: string; fileSize: number; tags?: string[]; - folderId?: string; status?: string; description?: string; featureInstanceId?: string; @@ -44,12 +43,6 @@ export interface WorkspaceFile { neutralize: boolean; } -export interface WorkspaceFolder { - id: string; - name: string; - parentId?: string; -} - export interface DataSource { id: string; connectionId: string; @@ -101,7 +94,6 @@ interface UseWorkspaceReturn { loadWorkflow: (workflowId: string) => void; resetToNew: () => void; files: WorkspaceFile[]; - folders: WorkspaceFolder[]; dataSources: DataSource[]; featureDataSources: FeatureDataSource[]; refreshFeatureDataSources: () => void; @@ -113,7 +105,6 @@ interface UseWorkspaceReturn { workflowId: string | null; workflowVersion: number; refreshFiles: () => void; - refreshFolders: () => void; refreshDataSources: () => void; dataSourceAccesses: DataSourceAccessEvent[]; /** @@ -135,7 +126,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { const [messages, setMessages] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [files, setFiles] = useState([]); - const [folders, setFolders] = useState([]); const [dataSources, setDataSources] = useState([]); const [featureDataSources, setFeatureDataSources] = useState([]); const [agentProgress, setAgentProgress] = useState(null); @@ -156,13 +146,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { .catch(err => console.error('Failed to load workspace files:', err)); }, [instanceId]); - const refreshFolders = useCallback(() => { - if (!instanceId) return; - api.get(`/api/workspace/${instanceId}/folders`) - .then(res => setFolders(res.data.folders || [])) - .catch(err => console.error('Failed to load workspace folders:', err)); - }, [instanceId]); - const refreshDataSources = useCallback(() => { if (!instanceId) return; api.get(`/api/workspace/${instanceId}/datasources`) @@ -180,10 +163,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { useEffect(() => { if (!instanceId) return; refreshFiles(); - refreshFolders(); refreshDataSources(); refreshFeatureDataSources(); - }, [instanceId, refreshFiles, refreshFolders, refreshDataSources, refreshFeatureDataSources]); + }, [instanceId, refreshFiles, refreshDataSources, refreshFeatureDataSources]); const loadWorkflow = useCallback((wfId: string) => { if (!instanceId || !wfId) return; @@ -511,7 +493,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { loadWorkflow, resetToNew, files, - folders, dataSources, featureDataSources, refreshFeatureDataSources, @@ -523,7 +504,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn { workflowId, workflowVersion, refreshFiles, - refreshFolders, refreshDataSources, dataSourceAccesses, loadedAttachedDataSourceIds, diff --git a/tsconfig.json b/tsconfig.json index 01490aa..1ffef60 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.test.json" } + { "path": "./tsconfig.node.json" } ] }