Merge pull request #68 from valueonag/int

Int
This commit is contained in:
Patrick Motsch 2026-05-01 00:01:55 +02:00 committed by GitHub
commit f6fa57180e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 3779 additions and 1442 deletions

View file

@ -23,6 +23,17 @@ export default tseslint.config(
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
'no-restricted-imports': [
'warn',
{
patterns: [
{
group: ['**/components/FolderTree/FolderTree*', '**/FolderTree/FolderTree*'],
message: 'FolderTree is deprecated — use FormGeneratorTable with groupingConfig instead.',
},
],
},
],
}, },
}, },
) )

View file

@ -4,6 +4,22 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES // 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 { export interface Connection {
id: string; id: string;
userId: string; userId: string;
@ -15,6 +31,8 @@ export interface Connection {
connectedAt: number; // Backend uses float for UTC timestamp in seconds connectedAt: number; // Backend uses float for UTC timestamp in seconds
lastChecked: 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 expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
[key: string]: any; // Allow additional properties [key: string]: any; // Allow additional properties
} }
@ -37,6 +55,19 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; 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<T> { export interface PaginatedResponse<T> {
@ -47,6 +78,8 @@ export interface PaginatedResponse<T> {
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
}; };
/** Current group tree for this (user, contextKey) pair — undefined if no grouping configured. */
groupTree?: TableGroupNode[];
} }
export interface CreateConnectionData { export interface CreateConnectionData {
@ -58,6 +91,8 @@ export interface CreateConnectionData {
externalUsername?: string; externalUsername?: string;
externalEmail?: string; externalEmail?: string;
status?: 'active' | 'expired' | 'revoked' | 'pending'; status?: 'active' | 'expired' | 'revoked' | 'pending';
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
connectedAt?: number; connectedAt?: number;
lastChecked?: number; lastChecked?: number;
expiresAt?: number; expiresAt?: number;
@ -103,6 +138,8 @@ export async function fetchConnections(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; 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) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -34,6 +34,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
groupId?: string;
saveGroupTree?: any[];
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -103,6 +105,8 @@ export async function fetchFiles(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; 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) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);
@ -186,110 +190,87 @@ export async function deleteFiles(
return uniqueIds.map(fileId => ({ success: true, fileId })); 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 { /** Patch scope for all files in a group (recursive) */
id: string; export async function patchGroupScope(
name: string;
parentId: string | null;
fileCount?: number;
mandateId?: string;
featureInstanceId?: string;
createdAt?: number;
scope?: string;
neutralize?: boolean;
}
export async function fetchFolders(
request: ApiRequestFunction, request: ApiRequestFunction,
parentId?: string | null groupId: string,
): Promise<FolderInfo[]> { scope: string
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<FolderInfo> {
return await request({
url: '/api/files/folders',
method: 'post',
data: { name, parentId: parentId || null },
});
}
export async function renameFolder(
request: ApiRequestFunction,
folderId: string,
name: string
): Promise<any> { ): Promise<any> {
return await request({ return await request({
url: `/api/files/folders/${folderId}`, url: `/api/files/groups/${groupId}/scope`,
method: 'put', method: 'patch',
data: { name }, 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, request: ApiRequestFunction,
folderId: string, groupId: string,
recursive: boolean = false neutralize: boolean
): Promise<any> { ): Promise<any> {
return await request({ 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<void> {
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<any> {
return await request({
url: `/api/files/groups/${groupId}`,
method: 'delete', method: 'delete',
params: { recursive }, params: { deleteItems },
}); });
} }
export async function moveFolder( /** Collect all file IDs belonging to a group recursively (client-side, from known groupTree) */
request: ApiRequestFunction, export function collectGroupItemIds(
folderId: string, groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
targetParentId: string | null groupId: string
): Promise<any> { ): string[] {
return await request({ const collect = (nodes: Array<{ id: string; itemIds: string[]; subGroups: any[] }>): string[] | null => {
url: `/api/files/folders/${folderId}/move`, for (const node of nodes) {
method: 'post', if (node.id === groupId) {
data: { targetParentId }, const ids: string[] = [...node.itemIds];
}); const sub = (n: { id: string; itemIds: string[]; subGroups: any[] }) => {
} ids.push(...n.itemIds);
n.subGroups.forEach(sub);
export async function moveFile( };
request: ApiRequestFunction, node.subGroups.forEach(sub);
fileId: string, return ids;
targetFolderId: string | null }
): Promise<any> { const found = collect(node.subGroups);
return await request({ if (found) return found;
url: `/api/files/${fileId}/move`, }
method: 'post', return null;
data: { targetFolderId }, };
}); return collect(groupTree) ?? [];
} }
// Note: The following operations require special handling (FormData, blob responses) // Note: The following operations require special handling (FormData, blob responses)

View file

@ -46,6 +46,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
groupId?: string;
saveGroupTree?: any[];
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -84,6 +86,8 @@ export async function fetchMandates(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; 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) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -49,6 +49,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
groupId?: string;
saveGroupTree?: any[];
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -110,6 +112,8 @@ export async function fetchPrompts(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; 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) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -48,6 +48,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
groupId?: string;
saveGroupTree?: any[];
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -152,6 +154,8 @@ export async function fetchUsers(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; 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) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -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);
}

View file

@ -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<ConnectorType, string> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
};
// ---------------------------------------------------------------------------
// 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<string, number> = { 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<void>;
isConnecting?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
isConnecting = false,
}) => {
const [state, setState] = useState<WizardState>({
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 = <K extends keyof KnowledgePreferences>(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 (
<Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */}
<div className={styles.stepper}>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className={[
styles.stepDot,
state.step === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')}
>
{state.step > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step 0: Connector ---- */}
{state.step === 0 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => setConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
</button>
))}
</div>
</div>
)}
{/* ---- Step 1: Consent ---- */}
{state.step === 1 && (
<div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}>
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?
</p>
<p className={styles.stepHint}>
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
</p>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)}
>
<FaCheck /> Ja, aufnehmen
</button>
<button
type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
Zurück
</button>
</div>
</div>
)}
{/* ---- Step 2: Preferences ---- */}
{state.step === 2 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
checked={!!state.prefs.neutralizeBeforeEmbed}
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Wissensdatenbank</span>
<span className={styles.summaryVal}>
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
</span>
</div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}>
<button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button
type="button"
className={styles.navConnect}
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default AddConnectionWizard;

View file

@ -4,7 +4,7 @@
* AI Chat sidebar for the GraphicalEditor. * AI Chat sidebar for the GraphicalEditor.
* Streams responses via SSE (same pattern as Workspace chat). * Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput: * 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) * - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/ */
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
@ -32,7 +32,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
export interface PendingFile { export interface PendingFile {
fileId: string; fileId: string;
fileName: string; fileName: string;
itemType?: 'file' | 'folder'; itemType?: 'file' | 'group';
} }
export interface EditorDataSource { export interface EditorDataSource {
@ -241,7 +241,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
}, [_handleSend]); }, [_handleSend]);
const _handleDragOver = useCallback((e: React.DragEvent) => { 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.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true); setTreeDropOver(true);
@ -252,6 +257,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
const _handleDrop = useCallback((e: React.DragEvent) => { const _handleDrop = useCallback((e: React.DragEvent) => {
setTreeDropOver(false); 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'); const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) { if (treeItemsJson) {
e.preventDefault(); e.preventDefault();
@ -282,11 +293,11 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
<span key={pf.fileId} style={{ <span key={pf.fileId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11, padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0', background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100', color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`, fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
}}> }}>
{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 && ( {onRemovePendingFile && (
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{ <button onClick={() => onRemovePendingFile(pf.fileId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1, border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,

View file

@ -280,9 +280,10 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
const reservedMimes = new Set([ const reservedMimes = new Set([
'application/json', 'application/json',
'application/tree-items', 'application/tree-items',
'application/group-file-ids',
'application/file-id', 'application/file-id',
'application/file-ids', 'application/file-ids',
'application/folder-id', 'application/group-id',
]); ]);
for (const mime of Array.from(e.dataTransfer.types)) { for (const mime of Array.from(e.dataTransfer.types)) {
if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue; if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue;

View file

@ -247,6 +247,34 @@
box-shadow: 0 3px 8px rgba(197, 48, 48, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important; box-shadow: 0 3px 8px rgba(197, 48, 48, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
} }
/* Compact mode (sidebar/UDB) */
.compact {
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
min-height: 0 !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: var(--color-text-secondary, #6b7280) !important;
border-radius: 3px !important;
flex-shrink: 0;
}
.compact .actionIcon {
font-size: 12px !important;
width: 12px !important;
height: 12px !important;
filter: none !important;
}
.compact:hover {
background: var(--color-secondary, #4A6FA5) !important;
color: #fff !important;
box-shadow: none !important;
transform: none !important;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.actionButtons { .actionButtons {

View file

@ -4,7 +4,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorControls.module.css'; import styles from './FormGeneratorControls.module.css';
import { Button } from '../../UiComponents/Button'; import { Button } from '../../UiComponents/Button';
import { IoIosRefresh } from "react-icons/io"; import { IoIosRefresh } from "react-icons/io";
import { FaTrash, FaDownload } from "react-icons/fa"; import { FaTrash, FaDownload, FaLayerGroup } from "react-icons/fa";
import type { AttributeType } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface // Generic field/column config interface
@ -77,6 +77,10 @@ export interface FormGeneratorControlsProps {
onSelectAllFiltered?: () => void; onSelectAllFiltered?: () => void;
selectAllFilteredActive?: boolean; selectAllFilteredActive?: boolean;
selectAllFilteredLoading?: boolean; selectAllFilteredLoading?: boolean;
// Grouping
groupingEnabled?: boolean;
onCreateGroup?: () => void;
activeGroupId?: string | null;
} }
export function FormGeneratorControls({ export function FormGeneratorControls({
@ -110,6 +114,9 @@ export function FormGeneratorControls({
onSelectAllFiltered, onSelectAllFiltered,
selectAllFilteredActive = false, selectAllFilteredActive = false,
selectAllFilteredLoading = false, selectAllFilteredLoading = false,
groupingEnabled = false,
onCreateGroup,
activeGroupId,
}: FormGeneratorControlsProps) { }: FormGeneratorControlsProps) {
const { t } = useLanguage(); const { t } = useLanguage();
@ -212,6 +219,16 @@ export function FormGeneratorControls({
{csvExporting ? t('Exportiere...') : 'CSV'} {csvExporting ? t('Exportiere...') : 'CSV'}
</button> </button>
)} )}
{groupingEnabled && onCreateGroup && (
<button
onClick={onCreateGroup}
className={styles.refreshButton}
title={t('Neue Gruppe erstellen')}
style={{ color: activeGroupId ? 'var(--color-primary, #4a6fa5)' : undefined }}
>
<span className={styles.refreshIcon}><FaLayerGroup /></span>
</button>
)}
{onRefresh && ( {onRefresh && (
<button <button
onClick={onRefresh} onClick={onRefresh}

View file

@ -9,6 +9,7 @@
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
position: relative;
} }
.title { .title {
@ -485,6 +486,56 @@
cursor: pointer; cursor: pointer;
} }
/* Items that live inside a group — subtle tint + left connector */
.tr.groupedItem {
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);
}
.tr.groupedItem:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-bg, #fff));
}
/**
* Hierarchy: set `--row-tree-indent` on the <tr> (px). Same row shifts checkbox, actions, and every `.td`.
* Folder rows attach this class from GroupRow.tsx; omit padding on `.folderCell` (inner strip uses `--group-indent`).
*/
.treeRowIndented {
--row-tree-indent: 0px;
}
.treeRowIndented > .selectColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .actionsColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .td {
box-sizing: border-box !important;
padding-top: 8px !important;
padding-right: 12px !important;
padding-bottom: 8px !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .folderCell:first-child {
box-sizing: border-box !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .selectColumn + .folderCell {
padding: 0 !important;
}
/* Selection Column */ /* Selection Column */
.selectColumn { .selectColumn {
text-align: center; text-align: center;
@ -1121,3 +1172,68 @@ tbody .actionsColumn {
gap: 4px; gap: 4px;
align-items: center; align-items: center;
} }
/* ── Compact sidebar mode ───────────────────────────────────────────────────── */
.compactMode {
gap: 0;
}
.compactMode .tableWrapper {
border: none;
}
/* Switch to auto layout so the action column shrinks to its content width
and the name column fills all remaining space naturally */
.compactMode .table {
table-layout: auto;
}
.compactMode .td {
padding: 5px 8px;
font-size: 12px;
border-right: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Let the browser size this column based on content */
width: auto;
min-width: unset;
max-width: unset;
}
/* Re-apply tree indent for data cells in compact mode */
.compactMode .treeRowIndented > .td {
padding-top: 5px !important;
padding-right: 8px !important;
padding-bottom: 5px !important;
padding-left: calc(8px + var(--row-tree-indent)) !important;
}
/* The action column: fixed narrow width, no background strip */
.compactMode .actionsColumn {
width: 28px !important;
min-width: 0 !important;
max-width: 28px !important;
padding: 2px !important;
background: transparent !important;
overflow: hidden;
white-space: nowrap;
}
.compactMode .actionButtons {
display: inline-flex !important;
width: auto !important;
gap: 0 !important;
justify-content: center;
}
/* Re-apply tree indent for action column in compact mode (overrides the default padding above) */
.compactMode .treeRowIndented > .actionsColumn {
padding: 2px !important;
}
/* Tighten group rows in compact mode */
.compactMode :global(.groupRow) {
font-size: 11px;
padding: 4px 8px;
}

View file

@ -0,0 +1,339 @@
/* ---------------------------------------------------------------------------
GroupFolderRow file-browser-style folder rows in the data table
--------------------------------------------------------------------------- */
.groupFolderRow {
background: var(--color-surface, #eef0f2);
border-bottom: 1px solid var(--color-border, #d4d9e0);
transition: background 0.12s;
user-select: none;
}
.groupFolderRow:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-surface, #eef0f2));
}
.groupFolderRow.dragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 18%, var(--color-surface, #eef0f2));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
/* Drop zone when another GROUP is dragged onto this group */
.groupFolderRow.dragOverGroup {
background: color-mix(in srgb, #d69e2e 18%, var(--color-surface, #eef0f2));
outline: 2px dashed #d69e2e;
outline-offset: -2px;
}
/* Cursor hint while dragging a group row */
.groupFolderRow[draggable="true"] {
cursor: grab;
}
.groupFolderRow[draggable="true"]:active {
cursor: grabbing;
}
/* Visual feedback: group is being dragged leftward to pop out */
.groupFolderRow.draggingOut {
opacity: 0.5;
border-left: 3px solid #d69e2e;
}
/* Folder subtree selection (aligned with tbody .tr.selected) */
.groupFolderRow.folderRowSubtreeFull {
background: rgba(124, 109, 216, 0.08);
background: rgba(var(--color-secondary-rgb), 0.08);
}
.groupFolderRow.folderRowSubtreePartial {
background: rgba(124, 109, 216, 0.04);
background: rgba(var(--color-secondary-rgb), 0.04);
}
.folderCell {
padding: 0 !important;
width: 100%;
}
.separator {
display: inline-block;
width: 1px;
height: 18px;
background: var(--color-border, #d4d9e0);
margin: 0 4px;
flex-shrink: 0;
}
.folderInner {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px 5px 0;
min-height: 34px;
width: 100%;
box-sizing: border-box;
}
.indent {
display: inline-block;
flex-shrink: 0;
}
/* Expand/collapse chevron button */
.chevronBtn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
flex-shrink: 0;
border-radius: 3px;
width: 20px;
height: 20px;
}
.chevronBtn:hover {
background: var(--color-primary-light, rgba(74,111,165,0.12));
}
/* Pure-CSS triangle arrow */
.chevronArrow {
display: inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid var(--color-text-secondary, #64748b);
transition: transform 0.15s;
flex-shrink: 0;
}
.chevronBtn:hover .chevronArrow {
border-left-color: var(--color-primary, #4a6fa5);
}
.chevronOpen .chevronArrow {
transform: rotate(90deg);
}
/* Folder icon (SVG via react-icons) */
.folderIcon {
font-size: 14px;
flex-shrink: 0;
line-height: 1;
margin-right: 2px;
color: var(--color-primary, #4a6fa5);
display: inline-flex;
align-items: center;
}
/* Group name text */
.groupName {
font-size: 13px;
font-weight: 500;
color: var(--color-text, #2d3748);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 300px;
}
.unnamed {
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
font-weight: 400;
}
/* Inline name input when editing */
.nameInput {
font-size: 13px;
font-weight: 500;
border: 1px solid var(--color-primary, #4a6fa5);
border-radius: 4px;
padding: 2px 8px;
outline: none;
background: var(--color-bg, #fff);
color: var(--color-text, #2d3748);
min-width: 160px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 20%, transparent);
}
/* Item count badge */
.badge {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 15%, transparent);
color: var(--color-primary, #4a6fa5);
border-radius: 10px;
padding: 0 7px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
flex-shrink: 0;
margin-left: 4px;
}
/* Drop hint text */
.dropHint {
font-size: 11px;
font-style: italic;
color: var(--color-primary, #4a6fa5);
margin-left: 4px;
animation: pulse 1s ease-in-out infinite alternate;
}
@keyframes pulse {
from { opacity: 0.6; }
to { opacity: 1.0; }
}
/* ── Bulk item action buttons (same type as per-row action buttons) ── */
.actions {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-right: 4px;
}
.actionBtn {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d9e0);
cursor: pointer;
padding: 3px 8px;
border-radius: 5px;
font-size: 12px;
color: var(--color-text-secondary, #64748b);
transition: background 0.1s, color 0.1s, border-color 0.1s;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
}
.actionBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actionBtn:not(:disabled):hover {
background: var(--color-surface, #eef0f2);
color: var(--color-text, #2d3748);
border-color: var(--color-primary, #4a6fa5);
}
.actionBtnDanger:not(:disabled):hover {
background: color-mix(in srgb, #e53e3e 10%, transparent);
color: #c53030;
border-color: #c53030;
}
/* ── Group management buttons (rename / add-sub / delete-group) ── */
.mgmtActions {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
border-left: 1px solid var(--color-border, #d4d9e0);
padding-left: 6px;
margin-left: 2px;
}
.mgmtBtn {
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 3px;
font-size: 11px;
color: var(--color-text-secondary, #94a3b8);
transition: background 0.1s, color 0.1s;
display: inline-flex;
align-items: center;
height: 22px;
}
.mgmtBtn:hover {
background: var(--color-border, #d4d9e0);
color: var(--color-text, #2d3748);
}
.mgmtBtnDanger:hover {
background: color-mix(in srgb, #e53e3e 12%, transparent);
color: #c53030;
}
/* ---------------------------------------------------------------------------
Breadcrumb row
--------------------------------------------------------------------------- */
.breadcrumbRow {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 6%, var(--color-bg, #fff));
}
.breadcrumbCell {
padding: 8px 14px !important;
}
.breadcrumbInner {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.backButton {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #4a6fa5);
font-size: 13px;
padding: 2px 8px;
border-radius: 5px;
transition: background 0.1s;
}
.backButton:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 12%, transparent);
}
.breadcrumbSep {
color: var(--color-text-secondary, #94a3b8);
}
.breadcrumbCurrent {
font-weight: 600;
color: var(--color-text, #2d3748);
}
/* ---------------------------------------------------------------------------
Ungrouped section row
--------------------------------------------------------------------------- */
.ungroupedRow {
background: var(--color-bg, #f8f9fa);
transition: background 0.12s, outline 0.12s;
}
/* Drop target: item or group dragged back to root */
.ungroupedDragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 10%, var(--color-bg, #f8f9fa));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
.ungroupedCell {
display: flex !important;
align-items: center;
gap: 6px;
padding: 5px 14px !important;
font-size: 12px;
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
border-top: 1px dashed var(--color-border, #d4d9e0);
}

View file

@ -0,0 +1,374 @@
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useConfirm } from '../../../hooks/useConfirm';
import styles from './GroupRow.module.css';
import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css';
import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable';
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GroupBulkAction {
icon?: React.ReactNode;
title?: string;
variant?: 'default' | 'danger';
onClick: () => void;
disabled?: boolean;
}
/** Horizontal shift per nesting level — keep in sync with item rows (`FormGeneratorTable`). */
export const GROUP_TREE_INDENT_STEP_PX = 20;
// ---------------------------------------------------------------------------
// GroupFolderRow
// ---------------------------------------------------------------------------
/** Folder row: optional select column, then one merged cell for folder UI (spans actions + data cols — no blank actions column). */
export interface GroupFolderTableCells {
showSelect: boolean;
/** `<td colSpan>` for folder strip = `detectedColumns.length` + (1 if table has an actions column). */
dataColumnsCount: number;
selectClassName: string;
selectTdStyle?: React.CSSProperties;
}
interface GroupFolderRowProps {
node: TableGroupNode;
depth: number;
/** Checkbox for “whole subtree”: select / clear all selectable visible items under this folder. */
subtreeSelect?: {
checked: boolean;
indeterminate: boolean;
disabled: boolean;
onToggle: () => void;
};
/** When set, use split `<td>` layout; omit single-cell colspan. */
tableCells?: GroupFolderTableCells;
/** Legacy single spanning cell — only used when `tableCells` is omitted. */
colSpan?: number;
visibleCount: number;
isExpanded: boolean;
isEditing: boolean;
/** True while an ITEM is dragged over this row (drop item into group). */
isDragOver: boolean;
/** True while a GROUP is dragged over this row (nest group inside). */
isDragOverFromGroup: boolean;
bulkActions?: GroupBulkAction[];
onToggle: () => void;
onEditCommit: (name: string) => void;
onEditCancel: () => void;
onRename: () => void;
onAddSub: () => void;
// Item drag-drop
onItemDragOver: (e: React.DragEvent) => void;
onItemDrop: (e: React.DragEvent) => void;
onItemDragLeave: () => void;
// Group drag (this row is draggable)
onGroupDragStart: (e: React.DragEvent) => void;
onGroupDragEnd: () => void;
onGroupDrag?: (e: React.DragEvent) => void;
/** True while this group is being dragged leftward to pop out one level */
isDraggingOut?: boolean;
/** Hide this row via display:none (keeps it in DOM so drag operations don't break) */
hidden?: boolean;
// Group drop (another group dropped onto this)
onGroupDragOver: (e: React.DragEvent) => void;
onGroupDrop: (e: React.DragEvent) => void;
onGroupDragLeave: () => void;
}
export function GroupFolderRow({
node,
depth,
subtreeSelect,
tableCells,
colSpan,
visibleCount,
isExpanded,
isEditing,
isDragOver,
isDragOverFromGroup,
isDraggingOut,
hidden,
bulkActions = [],
onToggle,
onEditCommit,
onEditCancel,
onRename,
onAddSub,
onItemDragOver,
onItemDrop,
onItemDragLeave,
onGroupDragStart,
onGroupDragEnd,
onGroupDrag,
onGroupDragOver,
onGroupDrop,
onGroupDragLeave,
}: GroupFolderRowProps) {
const { t } = useLanguage();
const { ConfirmDialog } = useConfirm();
const inputRef = useRef<HTMLInputElement>(null);
const subtreeCbRef = useRef<HTMLInputElement>(null);
const totalCount = node.itemIds.length;
useEffect(() => {
const el = subtreeCbRef.current;
if (!el || !subtreeSelect) return;
el.indeterminate = subtreeSelect.indeterminate;
}, [subtreeSelect?.indeterminate, subtreeSelect?.checked, subtreeSelect]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const indentPx = depth * GROUP_TREE_INDENT_STEP_PX;
const _rowClass = [
styles.groupFolderRow,
tableCells ? fgTableCss.treeRowIndented : '',
isDragOver ? styles.dragOver : '',
isDragOverFromGroup ? styles.dragOverGroup : '',
isDraggingOut ? styles.draggingOut : '',
subtreeSelect?.checked && !subtreeSelect?.disabled ? styles.folderRowSubtreeFull : '',
subtreeSelect?.indeterminate && !subtreeSelect?.checked ? styles.folderRowSubtreePartial : '',
].filter(Boolean).join(' ');
const mergedColSpan =
tableCells
? tableCells.dataColumnsCount
: (colSpan ?? 1);
const folderStripStyle =
({
'--group-indent': `${indentPx}px`,
...(tableCells
? { ['--row-tree-indent' as string]: `${depth * GROUP_TREE_INDENT_STEP_PX}px` }
: {}),
}) as React.CSSProperties;
const guardDragDecor = (
e: React.DragEvent,
relay: React.DragEventHandler | undefined,
) => {
const el = e.target as HTMLElement;
if (el.closest('input, button, textarea, label')) {
e.preventDefault();
e.stopPropagation();
return;
}
relay?.(e);
};
const folderCells = (
<>
{typeof document !== 'undefined' && ReactDOM.createPortal(<ConfirmDialog />, document.body)}
<tr
className={_rowClass}
style={{ ...folderStripStyle, display: hidden ? 'none' : undefined } as React.CSSProperties}
draggable={!isEditing}
onDragStart={(e) => guardDragDecor(e, onGroupDragStart)}
onDrag={(e) => guardDragDecor(e, onGroupDrag)}
onDragEnd={(e) => guardDragDecor(e, onGroupDragEnd)}
// item drag-over
onDragOver={(e) => {
// distinguish item vs group drag via dataTransfer type
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDragOver(e);
} else {
onItemDragOver(e);
}
}}
onDrop={(e) => {
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDrop(e);
} else {
onItemDrop(e);
}
}}
onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }}
onDragEnter={(e) => e.preventDefault()}
>
{tableCells?.showSelect && (
<td className={tableCells.selectClassName} style={tableCells.selectTdStyle}>
{subtreeSelect && (
<input
ref={subtreeCbRef}
type="checkbox"
checked={subtreeSelect.checked}
disabled={subtreeSelect.disabled}
onChange={(e) => { e.stopPropagation(); subtreeSelect.onToggle(); }}
onClick={(e) => e.stopPropagation()}
title={node.name ? t('Auswahl unter „{name}“', { name: node.name }) : t('Auswahl dieser Gruppe')}
aria-label={node.name ? t('Alle sichtbaren Einträge in „{name}“ auswählen', { name: node.name }) : t('Alle sichtbaren Einträge in dieser Gruppe auswählen')}
/>
)}
</td>
)}
<td colSpan={tableCells ? mergedColSpan : (colSpan ?? 1)} className={styles.folderCell}>
<div className={styles.folderInner}>
{/* Indent */}
{indentPx > 0 && <span className={styles.indent} style={{ width: indentPx }} />}
{/* Chevron */}
<button
className={`${styles.chevronBtn} ${isExpanded ? styles.chevronOpen : ''}`}
type="button"
onClick={(e) => { e.stopPropagation(); onToggle(); }}
title={isExpanded ? t('Zuklappen') : t('Aufklappen')}
tabIndex={-1}
>
<span className={styles.chevronArrow} />
</button>
{/* Folder icon */}
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{/* Name / inline input */}
{isEditing ? (
<input
ref={inputRef}
defaultValue={node.name}
className={styles.nameInput}
placeholder={t('Gruppenname…')}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditCommit(e.currentTarget.value);
if (e.key === 'Escape') onEditCancel();
}}
onBlur={(e) => onEditCommit(e.target.value)}
/>
) : (
<span className={styles.groupName} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
{node.name || <em className={styles.unnamed}>{t('(Unbenannt)')}</em>}
</span>
)}
{/* Item count badge */}
{!isEditing && (
<span className={styles.badge}>
{visibleCount < totalCount && totalCount > 0
? `${visibleCount} / ${totalCount}`
: String(totalCount)}
</span>
)}
{/* Drop hint */}
{(isDragOver || isDragOverFromGroup) && (
<span className={styles.dropHint}>
{isDragOverFromGroup ? t('Als Untergruppe ablegen') : t('Hierher ziehen')}
</span>
)}
{/* ── Bulk actions (delete all, custom batch) right after badge ── */}
{!isEditing && bulkActions.length > 0 && (
<>
<span className={styles.separator} />
<span className={styles.actions}>
{bulkActions.map((action, i) => (
<button
key={i}
type="button"
className={`${styles.actionBtn} ${action.variant === 'danger' ? styles.actionBtnDanger : ''}`}
title={action.title}
disabled={!!action.disabled}
onClick={(e) => { e.stopPropagation(); if (!action.disabled) action.onClick(); }}
>
{action.icon}
</button>
))}
</span>
</>
)}
{/* ── Group management: rename / add-subgroup ── */}
{!isEditing && (
<span className={styles.mgmtActions}>
<button type="button" onClick={(e) => { e.stopPropagation(); onRename(); }} title={t('Umbenennen')} className={styles.mgmtBtn}><FaPen /></button>
<button type="button" onClick={(e) => { e.stopPropagation(); onAddSub(); }} title={t('Untergruppe erstellen')} className={styles.mgmtBtn}><FaPlus /></button>
</span>
)}
<span style={{ flex: 1 }} />
</div>
</td>
</tr>
</>
);
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 (
<tr className={styles.breadcrumbRow}>
<td colSpan={colSpan} className={styles.breadcrumbCell}>
<div className={styles.breadcrumbInner}>
<button className={styles.backButton} onClick={onBack}>
{t('Alle anzeigen')}
</button>
<span className={styles.breadcrumbSep}></span>
<span className={styles.breadcrumbCurrent}>{groupName}</span>
{totalItems > 0 && (
<span style={{ color: 'var(--color-text-secondary, #94a3b8)', fontSize: '11px' }}>
({totalItems} {t('Einträge')})
</span>
)}
</div>
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// 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 (
<tr
className={`${styles.ungroupedRow} ${isDragOver ? styles.ungroupedDragOver : ''}`}
onDragOver={onDragOver}
onDrop={onDrop}
onDragLeave={onDragLeave}
onDragEnter={(e) => e.preventDefault()}
>
<td colSpan={colSpan} className={styles.ungroupedCell}>
<span className={styles.folderIcon}><FaList /></span>
{t('Nicht zugeordnet')}
<span className={styles.badge}>{count}</span>
{isDragOver && <span className={styles.dropHint}>{t('Aus Gruppe entfernen')}</span>}
</td>
</tr>
);
}

View file

@ -1,27 +1,36 @@
import React, { useState, useCallback, useRef, useMemo } from 'react'; import React, { useCallback, useRef, useMemo } from 'react';
import { FaFileImport } from 'react-icons/fa'; import { FaFileImport, FaPaperPlane } from 'react-icons/fa';
import type { UdbContext } from './UnifiedDataBar'; import type { UdbContext } from './UnifiedDataBar';
import api from '../../api'; import api from '../../api';
import FolderTree from '../../components/FolderTree/FolderTree'; import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
import type { FileNode } from '../../components/FolderTree/FolderTree';
import type { FileAction } from '../../components/FolderTree/actions/types';
import { useFileContext } from '../../contexts/FileContext';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { import {
importWorkflowFromFile, importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION, WORKFLOW_FILE_EXTENSION,
} from '../../api/workflowApi'; } from '../../api/workflowApi';
import { useToast } from '../../contexts/ToastContext'; 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 styles from './FilesTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; 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 { interface FilesTabProps {
context: UdbContext; context: UdbContext;
onFileSelect?: (fileId: string, fileName?: string) => void; 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 /** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
* den Graph-Editor importiert wurde. Aktivierung im Editor (Refresh-Liste, * den Graph-Editor importiert wurde. */
* Auto-Select) bleibt Aufgabe des Aufrufers. */
onWorkflowImported?: (workflowId: string) => void; onWorkflowImported?: (workflowId: string) => void;
} }
@ -29,57 +38,23 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [searchQuery, setSearchQuery] = useState(''); const [isDragOver, setIsDragOver] = React.useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = React.useState(false);
const [uploading, setUploading] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { const {
folders, data: files,
refreshFolders, pagination,
treeFileNodes, loading,
treeFilesLoading, refetch,
refreshTreeFiles, groupTree,
updateTreeFileNode, } = useUserFiles();
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
handleDownloadFolder,
} = useFileContext();
const _folderNodes = useMemo(() => { const { handleFileDelete, previewingFiles } = useFileOperations() as any;
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 _fileNodes: FileNode[] = useMemo(() => { const _tableRefetch = useCallback(async (params?: any) => {
let result = treeFileNodes; await refetch(params);
if (searchQuery.trim()) { }, [refetch]);
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 _uploadFiles = useCallback(async (fileList: FileList | File[]) => { const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return; if (!context.instanceId || uploading) return;
@ -93,13 +68,13 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}); });
} }
await _refreshAll(); await _tableRefetch();
} catch (err) { } catch (err) {
console.error('File upload failed:', err); console.error('File upload failed:', err);
} finally { } finally {
setUploading(false); setUploading(false);
} }
}, [context.instanceId, uploading, _refreshAll]); }, [context.instanceId, uploading, _tableRefetch]);
const _handleDragOver = useCallback((e: React.DragEvent) => { const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer.types.includes('Files')) {
@ -131,97 +106,63 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
} }
}, [_uploadFiles]); }, [_uploadFiles]);
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
}, [handleMoveFile]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { const columns = useMemo(() => [{
await contextMoveFiles(fileIds, targetFolderId); key: 'fileName',
}, [contextMoveFiles]); label: t('Dateiname'),
sortable: false,
filterable: false,
searchable: false,
formatter: (value: any, row: any) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
<ViewActionButton
row={row}
onView={() => {}}
idField="id"
nameField="fileName"
typeField="mimeType"
loadingStateName="previewingFiles"
hookData={{ previewingFiles }}
className={actionBtnStyles.compact}
/>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }}>
{value}
</span>
</div>
),
}], [t, previewingFiles]);
const _onDeleteFolder = useCallback(async (folderId: string) => { const _groupBulkActionsProvider = useMemo(() => {
await handleDeleteFolder(folderId); if (!onSendToChat) return undefined;
if (selectedFolderId === folderId) setSelectedFolderId(null); return (groupId: string, itemIds: string[]) => [
}, [handleDeleteFolder, selectedFolderId]); {
icon: <FaPaperPlane />,
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) => { const _customActions = useMemo(() => {
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(() => {
if (context.surface !== 'graphEditor') return []; if (context.surface !== 'graphEditor') return [];
return [ return [
{ {
id: 'workflow.openInEditor', id: 'workflow.openInEditor',
label: t('In Graph-Editor laden'), icon: <FaFileImport />,
icon: FaFileImport, title: t('In Graph-Editor laden'),
scope: 'file', onClick: async (row: any) => {
channels: ['inline', 'menu', 'sheet', 'drop'], if (!context.instanceId || !row?.id) return;
dragMime: 'application/json+workflow', if (!row.fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return;
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;
try { try {
const result = await importWorkflowFromFile(request, context.instanceId, { const result = await importWorkflowFromFile(request, context.instanceId, { fileId: row.id });
fileId: file.id,
});
const warnings = result?.warnings ?? []; const warnings = result?.warnings ?? [];
const wfId = result?.workflow?.id; const wfId = result?.workflow?.id;
if (warnings.length > 0) { if (warnings.length > 0) {
showSuccess( showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) }));
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', {
n: String(warnings.length),
}),
);
} else { } else {
showSuccess(t('Workflow importiert (deaktiviert).')); showSuccess(t('Workflow importiert (deaktiviert).'));
} }
@ -231,24 +172,11 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
showError(t('Import fehlgeschlagen: {msg}', { msg })); 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]); }, [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 <div className={styles.loading}>{t('Dateien laden')}</div>;
}
return ( return (
<div <div
className={styles.filesTab} className={styles.filesTab}
@ -280,7 +208,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
{uploading ? '...' : '+'} {uploading ? '...' : '+'}
</button> </button>
<button <button
onClick={_refreshAll} onClick={() => _tableRefetch()}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
> >
{'\u21BB'} {'\u21BB'}
@ -296,57 +224,33 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onChange={_handleFileInputChange} onChange={_handleFileInputChange}
/> />
<input <div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
type="text" <FormGeneratorTable
placeholder={t('Dateien suchen')} data={files || []}
value={searchQuery} columns={columns}
onChange={e => setSearchQuery(e.target.value)} apiEndpoint="/api/files/list"
style={{ loading={loading}
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, pagination={true}
border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px', pageSize={50}
}} searchable={false}
/> filterable={false}
sortable={false}
<div style={{ flex: 1, overflow: 'auto' }}> selectable={false}
<FolderTree onRowClick={(row: any) => onFileSelect?.(row.id, row.fileName)}
folders={_folderNodes} actionButtons={[]}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect ? (fileId: string) => {
const file = treeFileNodes.find(f => f.id === fileId);
onFileSelect(fileId, file?.fileName);
} : undefined}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle}
onFolderScopeChange={_onFolderScopeChange}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat}
customActions={_customActions} customActions={_customActions}
udbContext={context.surface} hookData={{
refetch: _tableRefetch,
pagination,
handleDelete: handleFileDelete,
previewingFiles,
groupTree,
}}
groupingConfig={{ contextKey: 'files/list', enabled: true }}
groupBulkActionsProvider={_groupBulkActionsProvider}
emptyMessage={t('Keine Dateien. Drag & Drop zum Hochladen.')}
compact={true}
/> />
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? t('Keine Dateien gefunden') : t('Keine Dateien. Drag & Drop zum Hochladen.')}
</div>
)}
</div> </div>
<div className={styles.legend}> <div className={styles.legend}>

View file

@ -7,8 +7,8 @@ import styles from './UnifiedDataBar.module.css';
export type UdbTab = 'chats' | 'files' | 'sources'; export type UdbTab = 'chats' | 'files' | 'sources';
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an `FolderTree.udbContext` /** Aufruf-Surface, in der die UDB gerade lebt. Wird an Custom-Actions
* weitergereicht, damit Custom-Actions (z. B. `workflow.openInEditor`) sich * (z. B. `workflow.openInEditor`) weitergereicht, damit sie sich
* pro Surface registrieren können. */ * pro Surface registrieren können. */
export type UdbSurface = export type UdbSurface =
| 'workspace' | 'workspace'
@ -28,7 +28,7 @@ export interface UdbContext {
export interface AddToChat_FileItem { export interface AddToChat_FileItem {
id: string; id: string;
type: 'file' | 'folder'; type: 'file' | 'group';
name: string; name: string;
} }

View file

@ -1,36 +1,9 @@
import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from 'react'; import React, { createContext, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import api from '../api';
import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles'; import { useFileOperations, type FilePreviewResult } from '../hooks/useFiles';
import type { FolderInfo } from '../api/fileApi';
import type { FileNode } from '../components/FolderTree/FolderTree';
export type { FolderInfo };
interface FileContextType { interface FileContextType {
folders: FolderInfo[];
foldersLoading: boolean;
refreshFolders: () => Promise<void>;
treeFileNodes: FileNode[];
treeFilesLoading: boolean;
loadTreeFiles: (folderId: string) => Promise<void>;
refreshTreeFiles: () => Promise<void>;
updateTreeFileNode: (fileId: string, patch: Partial<FileNode>) => void;
expandedFolderIds: Set<string>;
toggleFolderExpanded: (id: string) => void;
handleCreateFolder: (name: string, parentId: string | null) => Promise<void>;
handleRenameFolder: (folderId: string, newName: string) => Promise<void>;
handleDeleteFolder: (folderId: string) => Promise<void>;
handleMoveFolder: (folderId: string, targetParentId: string | null) => Promise<void>;
handleMoveFolders: (folderIds: string[], targetParentId: string | null) => Promise<void>;
handleMoveFile: (fileId: string, targetFolderId: string | null) => Promise<void>;
handleMoveFiles: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
handleDownloadFolder: (folderId: string, folderName: string) => Promise<void>;
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>; handleFileUpload: (file: File, workflowId?: string) => Promise<{ success: boolean; fileData?: any; error?: string }>;
handleFileDelete: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>;
handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<FilePreviewResult>; handleFilePreview: (fileId: string, fileName: string, mimeType?: string) => Promise<FilePreviewResult>;
handleFileDownload: (fileId: string, fileName: string) => Promise<void>; handleFileDownload: (fileId: string, fileName: string) => Promise<void>;
uploadingFile: boolean; uploadingFile: boolean;
@ -41,21 +14,6 @@ interface FileContextType {
export const FileContext = createContext<FileContextType | undefined>(undefined); export const FileContext = createContext<FileContextType | undefined>(undefined);
const _ROOT_KEY = '';
function _toFileNode(f: any): FileNode {
return {
id: f.id,
fileName: f.fileName || f.name || 'unknown',
mimeType: f.mimeType,
fileSize: f.fileSize,
folderId: f.folderId ?? null,
scope: f.scope,
neutralize: f.neutralize,
sysCreatedBy: f.sysCreatedBy,
};
}
export function FileProvider({ children }: { children: React.ReactNode }) { export function FileProvider({ children }: { children: React.ReactNode }) {
const { const {
handleFileUpload: hookHandleFileUpload, handleFileUpload: hookHandleFileUpload,
@ -68,254 +26,11 @@ export function FileProvider({ children }: { children: React.ReactNode }) {
downloadingFiles, downloadingFiles,
} = useFileOperations(); } = useFileOperations();
// ── Derive a session-scoped storage key from the current feature-instance URL ──
const location = useLocation();
const storageKey = useMemo(() => {
const match = location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
const instanceId = match ? match[3] : '_global';
return `folderTree-expandedIds-${instanceId}`;
}, [location.pathname]);
// ── Folder expanded state (persisted per feature-instance in sessionStorage) ──
const _loadExpanded = (key: string): Set<string> => {
try {
const stored = sessionStorage.getItem(key);
if (!stored) return new Set<string>();
const ids: string[] = JSON.parse(stored);
return new Set(ids.filter(id => id && id !== '__root__'));
} catch { return new Set<string>(); }
};
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => _loadExpanded(storageKey));
useEffect(() => {
setExpandedFolderIds(_loadExpanded(storageKey));
setTreeFilesMap(new Map());
setFolders([]);
}, [storageKey]);
// ── Folder state ──────────────────────────────────────────────────────
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [foldersLoading, setFoldersLoading] = useState(false);
const refreshFolders = useCallback(async () => {
setFoldersLoading(true);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
} finally {
setFoldersLoading(false);
}
}, []);
useEffect(() => { refreshFolders(); }, [refreshFolders, storageKey]);
// ── Tree files: lazy-loaded per expanded folder ───────────────────────
const [treeFilesMap, setTreeFilesMap] = useState<Map<string, FileNode[]>>(new Map());
const [treeFilesLoading, setTreeFilesLoading] = useState(false);
const loadTreeFiles = useCallback(async (folderId: string) => {
const key = folderId || _ROOT_KEY;
setTreeFilesLoading(true);
try {
const filterValue = folderId || null;
const resp = await api.get('/api/files/list', {
params: {
pagination: JSON.stringify({
page: 1,
pageSize: 500,
filters: { folderId: filterValue },
}),
},
});
const items: any[] = resp.data?.items || [];
setTreeFilesMap(prev => {
const next = new Map(prev);
next.set(key, items.map(_toFileNode));
return next;
});
} catch (err) {
console.error(`Failed to load tree files for folder ${folderId}:`, err);
} finally {
setTreeFilesLoading(false);
}
}, []);
const _removeTreeFiles = useCallback((folderId: string) => {
const key = folderId || _ROOT_KEY;
setTreeFilesMap(prev => {
const next = new Map(prev);
next.delete(key);
return next;
});
}, []);
const refreshTreeFiles = useCallback(async () => {
const keys = Array.from(treeFilesMap.keys());
if (!keys.includes(_ROOT_KEY)) keys.push(_ROOT_KEY);
await Promise.all(
keys.map(key => loadTreeFiles(key === _ROOT_KEY ? '' : key)),
);
}, [treeFilesMap, loadTreeFiles]);
const updateTreeFileNode = useCallback((fileId: string, patch: Partial<FileNode>) => {
setTreeFilesMap(prev => {
const next = new Map<string, FileNode[]>();
let found = false;
for (const [key, files] of prev) {
const updated = files.map(f => {
if (f.id === fileId) {
found = true;
return { ...f, ...patch };
}
return f;
});
next.set(key, updated);
}
return found ? next : prev;
});
}, []);
// Load root files on mount and on context change
useEffect(() => { loadTreeFiles(''); }, [loadTreeFiles, storageKey]);
// Load files for expanded folders on mount and context change
useEffect(() => {
expandedFolderIds.forEach(id => {
if (!treeFilesMap.has(id)) {
loadTreeFiles(id);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const treeFileNodes: FileNode[] = useMemo(() => {
const result: FileNode[] = [];
for (const [, files] of treeFilesMap) {
for (const f of files) result.push(f);
}
return result;
}, [treeFilesMap]);
// ── Toggle expand: load/unload tree files ─────────────────────────────
const toggleFolderExpanded = useCallback((id: string) => {
setExpandedFolderIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
loadTreeFiles(id);
}
try { sessionStorage.setItem(storageKey, JSON.stringify([...next])); } catch {}
return next;
});
}, [storageKey, loadTreeFiles]);
// ── Folder operations ─────────────────────────────────────────────────
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
await api.post('/api/files/folders', { name, parentId: parentId || null });
await refreshFolders();
}, [refreshFolders]);
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
await api.put(`/api/files/folders/${folderId}`, { name: newName });
await refreshFolders();
}, [refreshFolders]);
const handleDeleteFolder = useCallback(async (folderId: string) => {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
_removeTreeFiles(folderId);
await refreshFolders();
}, [refreshFolders, _removeTreeFiles]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refreshFolders();
}, [refreshFolders]);
const handleMoveFolders = useCallback(async (folderIds: string[], targetParentId: string | null) => {
await api.post('/api/files/batch-move', { folderIds, targetParentId });
await refreshFolders();
}, [refreshFolders]);
// ── File operations ───────────────────────────────────────────────────
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
await refreshTreeFiles();
await refreshFolders();
}, [refreshTreeFiles, refreshFolders]);
const handleMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await api.post('/api/files/batch-move', { fileIds, targetFolderId });
await refreshTreeFiles();
await refreshFolders();
}, [refreshTreeFiles, refreshFolders]);
const handleFileUpload = useCallback(async (file: File, workflowId?: string) => {
const result = await hookHandleFileUpload(file, workflowId);
if (result.success) {
await refreshTreeFiles();
await refreshFolders();
}
return result;
}, [hookHandleFileUpload, refreshTreeFiles, refreshFolders]);
const handleFileDelete = useCallback(async (fileId: string, onOptimisticDelete?: () => void) => {
const success = await hookHandleFileDelete(fileId, () => {
onOptimisticDelete?.();
});
if (success) {
await refreshTreeFiles();
await refreshFolders();
}
return success;
}, [hookHandleFileDelete, refreshTreeFiles, refreshFolders]);
const handleDownloadFolder = useCallback(async (folderId: string, folderName: string) => {
try {
const response = await api.get(`/api/files/folders/${folderId}/download`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${folderName}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Failed to download folder:', err);
}
}, []);
return ( return (
<FileContext.Provider <FileContext.Provider
value={{ value={{
folders, handleFileUpload: hookHandleFileUpload,
foldersLoading, handleFileDelete: hookHandleFileDelete,
refreshFolders,
treeFileNodes,
treeFilesLoading,
loadTreeFiles,
refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles,
handleDownloadFolder,
handleFileDelete,
handleFileUpload,
handleFilePreview, handleFilePreview,
handleFileDownload: async (fileId: string, fileName: string) => { handleFileDownload: async (fileId: string, fileName: string) => {
await handleFileDownload(fileId, fileName); await handleFileDownload(fileId, fileName);

View file

@ -49,7 +49,7 @@ export function formatApiError(error: any, defaultMessage: string): string {
// Type for API request options // Type for API request options
export interface ApiRequestOptions<T> { export interface ApiRequestOptions<T> {
url: string; url: string;
method: 'get' | 'post' | 'put' | 'delete'; method: 'get' | 'post' | 'put' | 'patch' | 'delete';
data?: T; data?: T;
params?: Record<string, string | number | boolean>; params?: Record<string, string | number | boolean>;
additionalConfig?: Record<string, any>; // For responseType, headers, etc. additionalConfig?: Record<string, any>; // For responseType, headers, etc.
@ -74,7 +74,7 @@ export function useApiRequest<RequestData = any, ResponseData = any>() {
// Generate cache key for GET requests (only cache GET requests) // Generate cache key for GET requests (only cache GET requests)
const cacheKey = method === 'get' ? generateCacheKey(url, method, params) : null; const cacheKey = method === 'get' ? generateCacheKey(url, method, params) : null;
// Mutating requests (POST/PUT/DELETE) invalidate the entire GET cache. // Mutating requests (POST/PUT/PATCH/DELETE) invalidate the entire GET cache.
// This ensures refetch() after create/update/delete returns fresh data. // This ensures refetch() after create/update/delete returns fresh data.
if (method !== 'get') { if (method !== 'get') {
requestCache.clear(); requestCache.clear();

View file

@ -22,6 +22,7 @@ import {
// Re-export types for backward compatibility // Re-export types for backward compatibility
export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse }; export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse };
export type { TableGroupNode } from '../api/connectionApi';
// Hook for managing connections // Hook for managing connections
export function useConnections() { export function useConnections() {
@ -34,6 +35,7 @@ export function useConnections() {
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
} | null>(null); } | null>(null);
const [groupTree, setGroupTree] = useState<import('../api/connectionApi').TableGroupNode[]>([]);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
const { request, isLoading, error } = useApiRequest<any, any>(); const { request, isLoading, error } = useApiRequest<any, any>();
@ -101,6 +103,9 @@ export function useConnections() {
if (data.pagination) { if (data.pagination) {
setPagination(data.pagination); setPagination(data.pagination);
} }
if (Array.isArray(data.groupTree)) {
setGroupTree(data.groupTree);
}
} else { } else {
// Handle non-paginated response (backward compatibility) // Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : []; const items = Array.isArray(data) ? data : [];
@ -708,6 +713,90 @@ export function useConnections() {
} }
}, [connections, request]); }, [connections, request]);
/**
* Generic wizard entry-point: create a connection of any supported type with
* optional knowledge consent + preferences, then immediately open the OAuth
* popup. The three individual `create*ConnectionAndAuth` methods are preserved
* for backward-compat but new wizard code should call this.
*/
const createConnectionAndAuth = async (
type: 'google' | 'msft' | 'clickup',
knowledgeIngestionEnabled: boolean,
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
): Promise<void> => {
if (isConnecting) return;
setIsConnecting(true);
try {
const newConnection = await createConnection({
type,
authority: type,
knowledgeIngestionEnabled,
knowledgePreferences: knowledgePreferences ?? null,
});
const connectResponse = await connectServiceApi(request, newConnection.id);
if (!connectResponse.authUrl) {
throw new Error('No OAuth URL received from backend');
}
const apiBaseUrl = getApiBaseUrl();
let authUrl = connectResponse.authUrl;
if (authUrl.startsWith('/')) authUrl = `${apiBaseUrl}${authUrl}`;
return await new Promise<void>((resolve, reject) => {
const popup = window.open(authUrl, `${type}-wizard`, 'width=500,height=600,scrollbars=yes,resizable=yes');
if (!popup) {
setIsConnecting(false);
reject(new Error('Popup was blocked. Please allow popups and try again.'));
return;
}
const SUCCESS_TYPES = new Set([
'google_connection_success', 'msft_connection_success', 'clickup_connection_success',
'google_auth_success',
]);
const ERROR_TYPES = new Set([
'google_connection_error', 'msft_connection_error', 'clickup_connection_error',
]);
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
setIsConnecting(false);
fetchConnections();
resolve();
}
}, 1000);
const messageListener = (event: MessageEvent) => {
const apiUrl = new URL(apiBaseUrl);
if (event.origin !== apiUrl.origin) return;
if (SUCCESS_TYPES.has(event.data.type)) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
fetchConnections();
resolve();
} else if (ERROR_TYPES.has(event.data.type)) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
reject(new Error(event.data.error || `${type} connection failed`));
}
};
window.addEventListener('message', messageListener);
});
} catch (error: any) {
setIsConnecting(false);
throw error;
}
};
return { return {
connections, connections,
data: connections, // Alias for FormGenerator compatibility data: connections, // Alias for FormGenerator compatibility
@ -726,6 +815,7 @@ export function useConnections() {
createClickupConnectionAndAuth, createClickupConnectionAndAuth,
createInfomaniakConnection, createInfomaniakConnection,
submitInfomaniakToken, submitInfomaniakToken,
createConnectionAndAuth,
isLoading, isLoading,
loading: isLoading, // Alias for FormGenerator compatibility loading: isLoading, // Alias for FormGenerator compatibility
isConnecting, isConnecting,
@ -741,7 +831,8 @@ export function useConnections() {
// Additional methods for FormGenerator // Additional methods for FormGenerator
updateOptimistically, updateOptimistically,
handleInlineUpdate, handleInlineUpdate,
fetchConnectionById fetchConnectionById,
groupTree,
}; };
} }

View file

@ -12,8 +12,8 @@ import {
updateFile as updateFileApi, updateFile as updateFileApi,
deleteFile as deleteFileApi, deleteFile as deleteFileApi,
deleteFiles as deleteFilesApi, deleteFiles as deleteFilesApi,
type FolderInfo,
} from '../api/fileApi'; } from '../api/fileApi';
import type { TableGroupNode } from '../api/connectionApi';
export interface FilePreviewResult { export interface FilePreviewResult {
success: boolean; success: boolean;
@ -73,6 +73,7 @@ export function useUserFiles() {
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
} | null>(null); } | null>(null);
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>(); const { request, isLoading: loading, error } = useApiRequest<null, UserFile[]>();
const { checkPermission } = usePermissions(); const { checkPermission } = usePermissions();
@ -172,6 +173,9 @@ export function useUserFiles() {
if (data.pagination) { if (data.pagination) {
setPagination(data.pagination); setPagination(data.pagination);
} }
if (Array.isArray((data as any).groupTree)) {
setGroupTree((data as any).groupTree);
}
} else { } else {
// Handle non-paginated response (backward compatibility) // Handle non-paginated response (backward compatibility)
console.log('📋 Processing non-paginated response:', { console.log('📋 Processing non-paginated response:', {
@ -325,6 +329,7 @@ export function useUserFiles() {
attributes, attributes,
permissions, permissions,
pagination, pagination,
groupTree,
fetchFileById, fetchFileById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
ensureAttributesLoaded ensureAttributesLoaded
@ -493,7 +498,6 @@ export function useFileOperations() {
file: globalThis.File, file: globalThis.File,
workflowId?: string, workflowId?: string,
featureInstanceId?: string, featureInstanceId?: string,
folderId?: string | null,
) => { ) => {
setUploadError(null); setUploadError(null);
setUploadingFile(true); setUploadingFile(true);
@ -518,9 +522,6 @@ export function useFileOperations() {
if (featureInstanceId) { if (featureInstanceId) {
formData.append('featureInstanceId', featureInstanceId); formData.append('featureInstanceId', featureInstanceId);
} }
if (folderId) {
formData.append('folderId', folderId);
}
// FormData is now correctly configured for backend // FormData is now correctly configured for backend
@ -696,87 +697,4 @@ export function useFileOperations() {
handleInlineUpdate, handleInlineUpdate,
isLoading isLoading
}; };
}
// ── Folder management hook ──────────────────────────────────────────────────
export function useFolders() {
const [folders, setFolders] = useState<FolderInfo[]>([]);
const [loading, setLoading] = useState(false);
const { showError } = useToast();
const refresh = useCallback(async () => {
setLoading(true);
try {
const response = await api.get('/api/files/folders');
const data = Array.isArray(response.data) ? response.data : [];
setFolders(data);
} catch (err) {
console.error('Failed to load folders:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { refresh(); }, [refresh]);
const handleCreateFolder = useCallback(async (name: string, parentId: string | null) => {
try {
await api.post('/api/files/folders', { name, parentId: parentId || null });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Folder creation failed');
throw err;
}
}, [refresh, showError]);
const handleRenameFolder = useCallback(async (folderId: string, newName: string) => {
try {
await api.put(`/api/files/folders/${folderId}`, { name: newName });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Rename failed');
throw err;
}
}, [refresh, showError]);
const handleDeleteFolder = useCallback(async (folderId: string) => {
try {
await api.delete(`/api/files/folders/${folderId}`, { params: { recursive: true } });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Delete failed');
throw err;
}
}, [refresh, showError]);
const handleMoveFolder = useCallback(async (folderId: string, targetParentId: string | null) => {
try {
await api.post(`/api/files/folders/${folderId}/move`, { targetParentId });
await refresh();
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
throw err;
}
}, [refresh, showError]);
const handleMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
try {
await api.post(`/api/files/${fileId}/move`, { targetFolderId });
} catch (err: any) {
showError(err?.response?.data?.detail || err?.message || 'Move failed');
throw err;
}
}, [showError]);
return {
folders,
loading,
refresh,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFile,
};
} }

View file

@ -13,6 +13,7 @@ import {
type AttributeDefinition, type AttributeDefinition,
type PaginationParams type PaginationParams
} from '../api/promptApi'; } from '../api/promptApi';
import type { TableGroupNode } from '../api/connectionApi';
// Re-export types for backward compatibility // Re-export types for backward compatibility
export type { Prompt, AttributeDefinition, PaginationParams }; export type { Prompt, AttributeDefinition, PaginationParams };
@ -34,6 +35,7 @@ export function usePrompts() {
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
} | null>(null); } | null>(null);
const [groupTree, setGroupTree] = useState<TableGroupNode[]>([]);
const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>(); const { request, isLoading: loading, error } = useApiRequest<null, Prompt[]>();
const { checkPermission } = usePermissions(); const { checkPermission } = usePermissions();
@ -99,6 +101,9 @@ export function usePrompts() {
if (data.pagination) { if (data.pagination) {
setPagination(data.pagination); setPagination(data.pagination);
} }
if (Array.isArray((data as any).groupTree)) {
setGroupTree((data as any).groupTree);
}
} else { } else {
// Handle non-paginated response (backward compatibility) // Handle non-paginated response (backward compatibility)
const items = Array.isArray(data) ? data : []; const items = Array.isArray(data) ? data : [];
@ -454,10 +459,11 @@ export function usePrompts() {
attributes, attributes,
permissions, permissions,
pagination, pagination,
groupTree,
fetchPromptById, fetchPromptById,
generateEditFieldsFromAttributes, generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes, generateCreateFieldsFromAttributes,
ensureAttributesLoaded // Generic function to ensure attributes are loaded ensureAttributesLoaded
}; };
} }

View file

@ -0,0 +1,90 @@
/* ConnectionsPage — supplemental styles for sync banner */
.syncBanner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem 0.875rem 1.125rem;
margin: 0 0 1rem;
background: linear-gradient(135deg, #fffbeb, #fef3c7);
border: 1px solid #fcd34d;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
animation: slidein 0.25s ease;
}
@keyframes slidein {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.syncSpinner {
flex-shrink: 0;
margin-top: 3px;
color: #d97706;
font-size: 1rem;
animation: spin 1.4s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.syncText {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.syncTitle {
font-weight: 600;
font-size: 0.9375rem;
color: #92400e;
}
.syncDetail {
font-size: 0.8125rem;
color: #78350f;
line-height: 1.5;
}
.syncDismiss {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: #b45309;
padding: 2px 4px;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
transition: background 0.15s;
}
.syncDismiss:hover {
background: rgba(0, 0, 0, 0.06);
}
/* Dark theme */
:global(.dark-theme) .syncBanner {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.3);
}
:global(.dark-theme) .syncTitle {
color: #fcd34d;
}
:global(.dark-theme) .syncDetail {
color: #fde68a;
}
:global(.dark-theme) .syncDismiss {
color: #fbbf24;
}
:global(.dark-theme) .syncSpinner {
color: #fbbf24;
}

View file

@ -5,17 +5,22 @@
* Follows the pattern established in AdminUsersPage/WorkflowsPage. * Follows the pattern established in AdminUsersPage/WorkflowsPage.
*/ */
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections'; import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks, FaCloud, FaSyncAlt } from 'react-icons/fa'; import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config'; import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css'; import styles from '../admin/Admin.module.css';
import bannerStyles from './ConnectionsPage.module.css';
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard';
import type { KnowledgePreferences } from '../../api/connectionApi';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { resolveColumnTypes } from '../../utils/columnTypeResolver';
const SYNC_BANNER_TTL_MS = 10 * 60 * 1000; // 10 minutes — conservative upper bound for bootstrap
export const ConnectionsPage: React.FC = () => { export const ConnectionsPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -32,15 +37,14 @@ export const ConnectionsPage: React.FC = () => {
updateOptimistically, updateOptimistically,
deleteConnection, deleteConnection,
handleInlineUpdate, handleInlineUpdate,
createGoogleConnectionAndAuth, createConnectionAndAuth,
createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth,
createInfomaniakConnection, createInfomaniakConnection,
submitInfomaniakToken, submitInfomaniakToken,
connectWithPopup, connectWithPopup,
refreshMicrosoftToken, refreshMicrosoftToken,
refreshGoogleToken, refreshGoogleToken,
isConnecting, isConnecting,
groupTree,
} = useConnections(); } = useConnections();
const [editingConnection, setEditingConnection] = useState<Connection | null>(null); const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
@ -48,6 +52,23 @@ export const ConnectionsPage: React.FC = () => {
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
const [adminConsentPending, setAdminConsentPending] = useState(false); const [adminConsentPending, setAdminConsentPending] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
// Banner shown while knowledge bootstrap is running in the background
const [syncBanner, setSyncBanner] = useState<{
connector: string;
startedAt: number;
} | null>(null);
const syncBannerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const showSyncBanner = (connector: string) => {
if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current);
setSyncBanner({ connector, startedAt: Date.now() });
syncBannerTimer.current = setTimeout(() => setSyncBanner(null), SYNC_BANNER_TTL_MS);
};
const dismissSyncBanner = () => {
if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current);
setSyncBanner(null);
};
// Infomaniak PAT modal: holds the pending connectionId (created up-front so the // Infomaniak PAT modal: holds the pending connectionId (created up-front so the
// user only commits if they actually paste a valid token; on cancel we delete it). // user only commits if they actually paste a valid token; on cancel we delete it).
@ -201,35 +222,20 @@ export const ConnectionsPage: React.FC = () => {
} }
}; };
// Guards prevent double-trigger while the OAuth popup is open, which would const handleWizardConnect = async (
// otherwise create additional orphan PENDING connections on every click. type: ConnectorType,
const handleCreateGoogle = async () => { knowledgeEnabled: boolean,
if (isConnecting) return; knowledgePreferences: KnowledgePreferences | null,
) => {
try { try {
await createGoogleConnectionAndAuth(); await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences);
refetch(); refetch();
if (knowledgeEnabled) {
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' };
showSyncBanner(LABELS[type] ?? type);
}
} catch (error) { } catch (error) {
console.error('Error creating Google connection:', error); console.error('Error creating connection via wizard:', error);
}
};
const handleCreateMicrosoft = async () => {
if (isConnecting) return;
try {
await createMicrosoftConnectionAndAuth();
refetch();
} catch (error) {
console.error('Error creating Microsoft connection:', error);
}
};
const handleCreateClickup = async () => {
if (isConnecting) return;
try {
await createClickupConnectionAndAuth();
refetch();
} catch (error) {
console.error('Error creating ClickUp connection:', error);
} }
}; };
@ -356,28 +362,13 @@ export const ConnectionsPage: React.FC = () => {
</button> </button>
{canCreate && ( {canCreate && (
<> <>
<button
className={styles.googleButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Google
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Microsoft
</button>
<button <button
type="button" type="button"
className={styles.clickupButton} className={styles.primaryButton}
onClick={handleCreateClickup} onClick={() => setWizardOpen(true)}
disabled={isConnecting} disabled={isConnecting}
title={t('ClickUp-Konto verbinden (OAuth oder Personal Token nach Anmeldung)')}
> >
<FaTasks /> ClickUp <FaPlus /> {t('Verbindung hinzufügen')}
</button> </button>
<button <button
type="button" type="button"
@ -393,6 +384,32 @@ export const ConnectionsPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Sync-in-progress banner */}
{syncBanner && (
<div className={bannerStyles.syncBanner}>
<FaSpinner className={bannerStyles.syncSpinner} />
<div className={bannerStyles.syncText}>
<span className={bannerStyles.syncTitle}>
{t('Wissensdatenbank wird synchronisiert')}
</span>
<span className={bannerStyles.syncDetail}>
{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 },
)}
</span>
</div>
<button
type="button"
className={bannerStyles.syncDismiss}
onClick={dismissSyncBanner}
aria-label={t('Hinweis schließen')}
>
<FaTimes />
</button>
</div>
)}
<div className={styles.tableContainer}> <div className={styles.tableContainer}>
<FormGeneratorTable <FormGeneratorTable
data={connections} data={connections}
@ -453,7 +470,9 @@ export const ConnectionsPage: React.FC = () => {
handleDelete: deleteConnection, handleDelete: deleteConnection,
handleInlineUpdate, handleInlineUpdate,
updateOptimistically, updateOptimistically,
groupTree,
}} }}
groupingConfig={{ contextKey: 'connections', enabled: true }}
emptyMessage={t('Keine Verbindungen gefunden')} emptyMessage={t('Keine Verbindungen gefunden')}
/> />
</div> </div>
@ -623,6 +642,13 @@ export const ConnectionsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<AddConnectionWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}
onConnect={handleWizardConnect}
isConnecting={isConnecting}
/>
</div> </div>
); );
}; };

View file

@ -1,33 +1,29 @@
/** /**
* FilesPage * FilesPage
* *
* Split-view file management: FolderTree on the left, FormGeneratorTable on the right. * Full-width file management using FormGeneratorTable with persistent grouping.
* The tree is the master it dictates which folder's files the table shows (paginated). * Organisation exclusively via groupTree/groupId no physical folder navigation.
* Tree files are managed by FileContext (lazy-loaded per expanded folder).
*/ */
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import api from '../../api';
import { useUserFiles, useFileOperations } from '../../hooks/useFiles'; import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
import { useFileContext } from '../../contexts/FileContext';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import FolderTree from '../../components/FolderTree/FolderTree'; import { FaSync, FaUpload, FaDownload, FaLock, FaLockOpen, FaFileArchive, FaTrash } from 'react-icons/fa';
import { useResizablePanels } from '../../hooks/useResizablePanels';
import { FaSync, FaUpload, FaDownload, FaFolderPlus } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext'; 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 styles from '../admin/Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { getUserDataCache } from '../../utils/userCache'; import { getUserDataCache } from '../../utils/userCache';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { GroupBulkAction } from '../../components/FormGenerator/GroupingManager/GroupRow';
interface UserFile { interface UserFile {
id: string; id: string;
fileName: string; fileName: string;
mimeType?: string; mimeType?: string;
fileSize?: number; fileSize?: number;
folderId?: string | null;
featureInstanceId?: string; featureInstanceId?: string;
[key: string]: any; [key: string]: any;
} }
@ -36,19 +32,9 @@ export const FilesPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt(); const { request } = useApiRequest();
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const { // ── Table data ────────────────────────────────────────────────────────
leftWidth, isDragging, handleMouseDown, containerRef,
} = useResizablePanels({
storageKey: 'filesPage-panelWidth',
defaultLeftWidth: 22,
minLeftWidth: 15,
maxLeftWidth: 40,
});
// ── Table data (paginated, filtered by selectedFolderId) ──────────────
const { const {
data: tableFiles, data: tableFiles,
attributes, attributes,
@ -57,6 +43,7 @@ export const FilesPage: React.FC = () => {
error, error,
refetch: tableRefetch, refetch: tableRefetch,
pagination, pagination,
groupTree,
fetchFileById, fetchFileById,
updateFileOptimistically, updateFileOptimistically,
} = useUserFiles(); } = useUserFiles();
@ -74,127 +61,22 @@ export const FilesPage: React.FC = () => {
previewingFiles, previewingFiles,
} = useFileOperations(); } = 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<UserFile | null>(null); const [editingFile, setEditingFile] = useState<UserFile | null>(null);
const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]); const [selectedFiles, setSelectedFiles] = useState<UserFile[]>([]);
const [treeSelectedIds, setTreeSelectedIds] = useState<Set<string>>(new Set());
const [highlightedFileId, setHighlightedFileId] = useState<string | null>(null);
// ── Table refetch: filter by real folderId ─────────────────────────── // ── Table refetch wrapper ──────────────────────────────────────────────
const _tableRefetch = useCallback(async (params?: any) => { const _tableRefetch = useCallback(async (params?: any) => {
const nextParams = { ...(params || {}) }; await tableRefetch(params);
const nextFilters = { ...(nextParams.filters || {}) }; }, [tableRefetch]);
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]);
const _refreshAll = useCallback(async () => { const _refreshAll = useCallback(async () => {
await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); await _tableRefetch({ page: 1, pageSize: 25 });
}, [_tableRefetch, refreshTreeFiles, refreshFolders]); }, [_tableRefetch]);
const _handleScopeChange = useCallback(async (fileId: string, newScope: string) => { // Initial fetch
updateTreeFileNode(fileId, { scope: newScope }); useEffect(() => {
try { _tableRefetch({ page: 1, pageSize: 25 });
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); }, [_tableRefetch]);
_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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem', alignItems: 'center' }}>
<div style={{ fontWeight: 600 }}>
{selectedFolderName
? t('Der Ordner „{name}" ist leer.', { name: selectedFolderName })
: t('Dieser Ordner ist leer.')}
</div>
<div style={{ color: 'var(--text-muted, #64748b)' }}>
{t('Lade eine neue Datei hoch oder verschiebe bestehende Dateien hierher.')}
</div>
</div>
);
}, [selectedFolderId, selectedFolderName, t]);
// ── Columns ─────────────────────────────────────────────────────────── // ── Columns ───────────────────────────────────────────────────────────
const columns = useMemo(() => { const columns = useMemo(() => {
@ -225,9 +107,6 @@ export const FilesPage: React.FC = () => {
maxWidth: 250, maxWidth: 250,
displayField: 'sysCreatedByLabel', displayField: 'sysCreatedByLabel',
} as any); } 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({ cols.push({
key: 'sysModifiedAt', key: 'sysModifiedAt',
label: t('Geaendert am'), label: t('Geaendert am'),
@ -249,50 +128,6 @@ export const FilesPage: React.FC = () => {
const currentUserId = useMemo(() => getUserDataCache()?.id || '', []); const currentUserId = useMemo(() => getUserDataCache()?.id || '', []);
const _isOwned = useCallback((row: UserFile) => row.sysCreatedBy === currentUserId, [currentUserId]); 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 ────────────────────────────────────────────── // ── Table event handlers ──────────────────────────────────────────────
const handleEditClick = async (file: UserFile) => { const handleEditClick = async (file: UserFile) => {
const fullFile = await fetchFileById(file.id); const fullFile = await fetchFileById(file.id);
@ -302,7 +137,7 @@ export const FilesPage: React.FC = () => {
const handleEditSubmit = async (data: Partial<UserFile>) => { const handleEditSubmit = async (data: Partial<UserFile>) => {
if (!editingFile) return; if (!editingFile) return;
const changes: Record<string, any> = {}; const changes: Record<string, any> = {};
const editableFields = ['fileName', 'scope', 'tags', 'description', 'folderId', 'neutralize'] as const; const editableFields = ['fileName', 'scope', 'tags', 'description', 'neutralize'] as const;
for (const field of editableFields) { for (const field of editableFields) {
if (data[field] !== undefined && data[field] !== editingFile[field]) { if (data[field] !== undefined && data[field] !== editingFile[field]) {
changes[field] = data[field]; changes[field] = data[field];
@ -314,19 +149,19 @@ export const FilesPage: React.FC = () => {
const result = await handleFileUpdate(editingFile.id, changes); const result = await handleFileUpdate(editingFile.id, changes);
if (result.success) { if (result.success) {
setEditingFile(null); setEditingFile(null);
await Promise.all([_tableRefetch(), refreshTreeFiles()]); await _tableRefetch();
} }
}; };
const handleDelete = async (file: UserFile) => { const handleDelete = async (file: UserFile) => {
const success = await handleFileDelete(file.id); const success = await handleFileDelete(file.id);
if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); if (success) await _tableRefetch();
}; };
const handleDeleteMultiple = async (filesToDelete: UserFile[]) => { const handleDeleteMultiple = async (filesToDelete: UserFile[]) => {
const ids = filesToDelete.map(f => f.id); const ids = filesToDelete.map(f => f.id);
const success = await handleFileDeleteMultiple(ids); const success = await handleFileDeleteMultiple(ids);
if (success) await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); if (success) await _tableRefetch();
}; };
const handleDownload = async (file: UserFile) => { const handleDownload = async (file: UserFile) => {
@ -341,11 +176,11 @@ export const FilesPage: React.FC = () => {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
for (const file of Array.from(picked)) { 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 (result?.success) successCount++; else errorCount++;
} }
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
await Promise.all([_tableRefetch(), refreshTreeFiles(), refreshFolders()]); await _tableRefetch();
if (successCount > 0) { if (successCount > 0) {
showSuccess( showSuccess(
t('Upload erfolgreich'), t('Upload erfolgreich'),
@ -359,12 +194,54 @@ export const FilesPage: React.FC = () => {
} }
}; };
const _handleNewFolder = useCallback(async () => { const _groupBulkActionsProvider = useCallback((groupId: string, itemIds: string[]): GroupBulkAction[] => {
const name = await promptInput(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') }); return [
if (name?.trim()) { {
await handleCreateFolder(name.trim(), selectedFolderId); icon: <FaLock />,
} title: t('Scope: personal'),
}, [handleCreateFolder, selectedFolderId, promptInput, t]); 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: <FaLockOpen />,
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: <FaFileArchive />,
title: t('ZIP herunterladen'),
onClick: async () => {
try { await downloadGroupZip(groupId); }
catch (e) { showError(t('Fehler'), String(e)); }
},
disabled: itemIds.length === 0,
},
{
icon: <FaTrash />,
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<HTMLTableRowElement>, row: UserFile) => { const _onRowDragStart = useCallback((e: React.DragEvent<HTMLTableRowElement>, row: UserFile) => {
const isInSelection = selectedFiles.some(f => f.id === row.id); const isInSelection = selectedFiles.some(f => f.id === row.id);
@ -373,11 +250,11 @@ export const FilesPage: React.FC = () => {
} else { } else {
e.dataTransfer.setData('application/file-id', row.id); e.dataTransfer.setData('application/file-id', row.id);
} }
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'copyMove';
}, [selectedFiles]); }, [selectedFiles]);
const formAttributes = useMemo(() => { 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)); return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]); }, [attributes]);
@ -411,155 +288,88 @@ export const FilesPage: React.FC = () => {
<p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p> <p className={styles.pageSubtitle}>{t('Dateiverwaltung')}</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => _refreshAll()} disabled={tableLoading}> <button className={styles.secondaryButton} onClick={_refreshAll} disabled={tableLoading}>
<FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')} <FaSync className={tableLoading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button> </button>
</div> </div>
</div> </div>
<div <div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
ref={containerRef as React.RefObject<HTMLDivElement>}
style={{ display: 'flex', flex: 1, overflow: 'hidden', minHeight: 0, position: 'relative' }}
>
{/* Left panel: FolderTree */}
<div style={{ <div style={{
width: `${leftWidth}%`, display: 'flex', gap: 8, padding: '8px 12px',
minWidth: 0, borderBottom: '1px solid var(--color-border, #e0e0e0)',
overflow: 'auto', flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
borderRight: '1px solid var(--color-border, #e0e0e0)',
padding: '8px 4px',
}}> }}>
<FolderTree {canCreate && (
folders={folderNodes} <button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
files={treeFileNodes} <FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')}
showFiles={true} </button>
selectedFolderId={selectedFolderId} )}
onSelect={setSelectedFolderId}
onFileSelect={_handleTreeFileSelect}
selectedItemIds={treeSelectedIds}
onSelectionChange={setTreeSelectedIds}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={async (folderId) => {
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}
/>
</div> </div>
{/* Resizable divider */} <div style={{ flex: 1, overflow: 'auto' }}>
<div <FormGeneratorTable
onMouseDown={handleMouseDown} data={tableFiles || []}
style={{ columns={columns}
width: 6, apiEndpoint="/api/files/list"
cursor: 'col-resize', loading={tableLoading}
background: isDragging ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.2))' : 'transparent', pagination={true}
transition: isDragging ? 'none' : 'background 0.15s', pageSize={25}
flexShrink: 0, searchable={true}
zIndex: 10, filterable={true}
}} sortable={true}
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }} selectable={true}
onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }} onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
/> rowDraggable={true}
onRowDragStart={_onRowDragStart}
{/* Right panel: File table */} actionButtons={[
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}> {
<div style={{ type: 'view' as const,
display: 'flex', gap: 8, padding: '8px 12px', onAction: () => {},
borderBottom: '1px solid var(--color-border, #e0e0e0)', title: t('Vorschau'),
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap', idField: 'id',
}}> nameField: 'fileName',
<button className={styles.secondaryButton} onClick={_handleNewFolder}> typeField: 'mimeType',
<FaFolderPlus /> {t('Neuer Ordner')} loadingStateName: 'previewingFiles',
</button> },
{canCreate && ( ...(canUpdate ? [{
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}> type: 'edit' as const,
<FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')} onAction: handleEditClick,
</button> title: t('Bearbeiten'),
)} disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann bearbeiten') } : false,
</div> }] : []),
...(canDelete ? [{
<div style={{ flex: 1, overflow: 'auto' }}> type: 'delete' as const,
<FormGeneratorTable title: t('Löschen'),
data={tableFiles || []} loading: (row: UserFile) => deletingFiles.has(row.id),
columns={columns} disabled: (row: UserFile) => !_isOwned(row) ? { disabled: true, message: t('Nur Eigentümer kann löschen') } : false,
apiEndpoint="/api/files/list" }] : []),
loading={tableLoading} ]}
pagination={true} customActions={[
pageSize={25} {
searchable={true} id: 'download',
filterable={true} icon: <FaDownload />,
sortable={true} onClick: handleDownload,
selectable={true} title: t('Herunterladen'),
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])} loading: (row: UserFile) => downloadingFiles.has(row.id),
rowDraggable={true} },
onRowDragStart={_onRowDragStart} ]}
getRowDataAttributes={(row: UserFile) => onDelete={handleDelete}
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' }) onDeleteMultiple={handleDeleteMultiple}
} hookData={{
actionButtons={[ refetch: _tableRefetch,
{ pagination,
type: 'view' as const, permissions,
onAction: () => { /* ContentPreview fetches the file itself once the popup opens */ }, handleDelete: handleFileDelete,
title: t('Vorschau'), handleInlineUpdate,
idField: 'id', updateOptimistically: updateFileOptimistically,
nameField: 'fileName', previewingFiles,
typeField: 'mimeType', groupTree,
loadingStateName: 'previewingFiles', }}
}, groupingConfig={{ contextKey: 'files/list', enabled: true }}
...(canUpdate ? [{ groupBulkActionsProvider={_groupBulkActionsProvider}
type: 'edit' as const, emptyMessage={t('Keine Dateien gefunden')}
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: <FaDownload />,
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}
/>
</div>
</div> </div>
</div> </div>
@ -591,7 +401,6 @@ export const FilesPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<PromptDialog />
</div> </div>
); );
}; };

View file

@ -34,6 +34,7 @@ export const PromptsPage: React.FC = () => {
loading, loading,
error, error,
refetch, refetch,
groupTree,
fetchPromptById, fetchPromptById,
updateOptimistically, updateOptimistically,
} = usePrompts(); } = usePrompts();
@ -236,7 +237,9 @@ export const PromptsPage: React.FC = () => {
handleDelete: handlePromptDelete, handleDelete: handlePromptDelete,
handleInlineUpdate, handleInlineUpdate,
updateOptimistically, updateOptimistically,
groupTree,
}} }}
groupingConfig={{ contextKey: 'prompts', enabled: true }}
emptyMessage={t('Keine Prompts gefunden')} emptyMessage={t('Keine Prompts gefunden')}
/> />
</div> </div>

View file

@ -9,7 +9,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { useFileContext } from '../../../contexts/FileContext'; import { useFileOperations, useUserFiles } from '../../../hooks/useFiles';
import { useConnections, type Connection } from '../../../hooks/useConnections'; import { useConnections, type Connection } from '../../../hooks/useConnections';
import { import {
getNeutralizationConfig, getNeutralizationConfig,
@ -178,7 +178,8 @@ const ConfigTab: React.FC = () => {
const PlaygroundTab: React.FC = () => { const PlaygroundTab: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const { refreshTreeFiles: refetchFiles, handleFileDownload } = useFileContext(); const { handleFileDownload } = useFileOperations();
const { refetch: refetchFiles } = useUserFiles();
const { connections } = useConnections(); const { connections } = useConnections();
const msftConnections = connections.filter( const msftConnections = connections.filter(

View file

@ -418,6 +418,8 @@ export const TeamsbotSessionView: React.FC = () => {
e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('Files') ||
e.dataTransfer.types.includes('application/file-id') || e.dataTransfer.types.includes('application/file-id') ||
e.dataTransfer.types.includes('application/file-ids') || 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.dataTransfer.types.includes('application/tree-items')
) { ) {
e.preventDefault(); e.preventDefault();
@ -432,6 +434,8 @@ export const TeamsbotSessionView: React.FC = () => {
e.dataTransfer.types.includes('Files') || e.dataTransfer.types.includes('Files') ||
e.dataTransfer.types.includes('application/file-id') || e.dataTransfer.types.includes('application/file-id') ||
e.dataTransfer.types.includes('application/file-ids') || 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.dataTransfer.types.includes('application/tree-items')
) { ) {
e.preventDefault(); e.preventDefault();
@ -453,6 +457,17 @@ export const TeamsbotSessionView: React.FC = () => {
directorDragCounterRef.current = 0; directorDragCounterRef.current = 0;
setDirectorDragOver(false); 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'); const fileIdsJson = e.dataTransfer.getData('application/file-ids');
if (fileIdsJson) { if (fileIdsJson) {
try { try {
@ -469,10 +484,16 @@ export const TeamsbotSessionView: React.FC = () => {
return; return;
} }
const groupId = e.dataTransfer.getData('application/group-id');
if (groupId) {
_addDirectorFile(groupId);
return;
}
const treeItemsJson = e.dataTransfer.getData('application/tree-items'); const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) { if (treeItemsJson) {
try { 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)); items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name));
} catch { /* ignore malformed */ } } catch { /* ignore malformed */ }
return; return;

View file

@ -104,9 +104,11 @@ export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities })
case 'connectNodes': return t('Knoten verbinden'); case 'connectNodes': return t('Knoten verbinden');
case 'copyFile': return t('Datei kopieren'); case 'copyFile': return t('Datei kopieren');
case 'createChart': return t('Diagramm erstellen'); case 'createChart': return t('Diagramm erstellen');
case 'createGroup': return t('Gruppe anlegen');
case 'createFolder': return t('Ordner anlegen'); case 'createFolder': return t('Ordner anlegen');
case 'createRecord': return t('Datensatz erstellen'); case 'createRecord': return t('Datensatz erstellen');
case 'deleteFile': return t('Datei löschen'); case 'deleteFile': return t('Datei löschen');
case 'deleteGroup': return t('Gruppe löschen');
case 'deleteFolder': return t('Ordner löschen'); case 'deleteFolder': return t('Ordner löschen');
case 'deleteRecord': return t('Datensatz löschen'); case 'deleteRecord': return t('Datensatz löschen');
case 'describeImage': return t('Bild beschreiben'); case 'describeImage': return t('Bild beschreiben');
@ -123,10 +125,18 @@ export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities })
case 'listAvailableNodeTypes': return t('Verfügbare Knotentypen auflisten'); case 'listAvailableNodeTypes': return t('Verfügbare Knotentypen auflisten');
case 'listConnections': return t('Verbindungen auflisten'); case 'listConnections': return t('Verbindungen auflisten');
case 'listFiles': return t('Dateien 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 'listFolders': return t('Ordner auflisten');
case 'listTables': return t('Tabellen auflisten'); case 'listTables': return t('Tabellen auflisten');
case 'listWorkflowHistory': return t('Workflow-Verlauf'); case 'listWorkflowHistory': return t('Workflow-Verlauf');
case 'moveFile': return t('Datei verschieben'); 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 'moveFolder': return t('Ordner verschieben');
case 'neutralizeData': return t('Daten neutralisieren'); case 'neutralizeData': return t('Daten neutralisieren');
case 'outlook_composeAndDraftReply': return t('Outlook-Antwort entwerfen'); case 'outlook_composeAndDraftReply': return t('Outlook-Antwort entwerfen');

View file

@ -3,7 +3,14 @@
* voice toggle (generic audio capture hook), and data source selection. * 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 { ProviderMultiSelect } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry'; import { getPageIcon } from '../../../config/pageRegistry';
@ -14,16 +21,25 @@ import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspac
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext'; import { useVoiceCatalog } from '../../../contexts/VoiceCatalogContext';
interface PendingFile { export interface TreeItemDrop {
fileId: string; id: string;
fileName: string; type: 'file' | 'group';
itemType?: 'file' | 'folder'; name: string;
} }
interface TreeItemDrop { /** An attachment chip shown in the input bar.
id: string; * Groups are kept as-is (show as single chip); file IDs are resolved at send-time. */
type: 'file' | 'folder'; export type AttachmentItem =
name: string; | { 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<string[]>;
export interface WorkspaceInputHandle {
attachFileIds: (ids: string[]) => void;
attachTreeItems: (items: TreeItemDrop[]) => Promise<void>;
ingestTreeDataTransfer: (dt: DataTransfer) => Promise<boolean>;
} }
interface WorkspaceInputProps { interface WorkspaceInputProps {
@ -34,14 +50,12 @@ interface WorkspaceInputProps {
files: WorkspaceFile[]; files: WorkspaceFile[];
dataSources: DataSource[]; dataSources: DataSource[];
featureDataSources?: FeatureDataSource[]; featureDataSources?: FeatureDataSource[];
pendingFiles?: PendingFile[]; resolveTreeItemsToFileIds: ResolveTreeItemsToFileIds;
onRemovePendingFile?: (fileId: string) => void;
onFileUploadClick?: () => void; onFileUploadClick?: () => void;
uploading?: boolean; uploading?: boolean;
providerSelection?: ProviderSelection; providerSelection?: ProviderSelection;
onProviderSelectionChange?: (selection: ProviderSelection) => void; onProviderSelectionChange?: (selection: ProviderSelection) => void;
isMobile?: boolean; isMobile?: boolean;
onTreeItemsDrop?: (items: TreeItemDrop[]) => void;
onFeatureSourceDrop?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => 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; onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
pendingAttachDsId?: string; pendingAttachDsId?: string;
@ -51,36 +65,62 @@ interface WorkspaceInputProps {
onPasteAsFile?: (file: File) => void; onPasteAsFile?: (file: File) => void;
draftAppend?: string; draftAppend?: string;
onDraftAppendConsumed?: () => void; 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; workflowId?: string | null;
loadedAttachedDataSourceIds?: string[]; loadedAttachedDataSourceIds?: string[];
loadedAttachedFeatureDataSourceIds?: string[]; loadedAttachedFeatureDataSourceIds?: string[];
loadedNonce?: number; loadedNonce?: number;
} }
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ 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<WorkspaceInputHandle, WorkspaceInputProps>(function WorkspaceInput({
instanceId,
onSend, onSend,
isProcessing, isProcessing,
onStop, onStop,
files, files,
dataSources, dataSources,
featureDataSources = [], featureDataSources = [],
pendingFiles = [], resolveTreeItemsToFileIds,
onRemovePendingFile,
onFileUploadClick, onFileUploadClick,
uploading = false, uploading = false,
providerSelection, providerSelection,
onProviderSelectionChange, onProviderSelectionChange,
isMobile = false, isMobile = false,
onTreeItemsDrop,
onFeatureSourceDrop, onFeatureSourceDrop,
onDataSourceDrop, onDataSourceDrop,
pendingAttachDsId, pendingAttachDsId,
@ -94,23 +134,37 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
loadedAttachedDataSourceIds, loadedAttachedDataSourceIds,
loadedAttachedFeatureDataSourceIds, loadedAttachedFeatureDataSourceIds,
loadedNonce, loadedNonce,
}) => { }, ref) {
const { t } = useLanguage(); const { t } = useLanguage();
const { languages: voiceCatalogLanguages } = useVoiceCatalog(); const { languages: voiceCatalogLanguages } = useVoiceCatalog();
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [autocompleteFilter, setAutocompleteFilter] = useState('');
const [treeDropOver, setTreeDropOver] = useState(false); const [treeDropOver, setTreeDropOver] = useState(false);
const textareaAreaDragDepth = useRef(0);
const [voiceActive, setVoiceActive] = useState(false); const [voiceActive, setVoiceActive] = useState(false);
const [voiceLanguage, setVoiceLanguage] = useState('de-DE'); const [voiceLanguage, setVoiceLanguage] = useState('de-DE');
const [showLangPicker, setShowLangPicker] = useState(false); const [showLangPicker, setShowLangPicker] = useState(false);
const _sttPrefsLoaded = useRef(false); const _sttPrefsLoaded = useRef(false);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]); const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [neutralizeActive, setNeutralizeActive] = useState(false); const [neutralizeActive, setNeutralizeActive] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(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(() => { useEffect(() => {
if (draftAppend) { if (draftAppend) {
setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend); setPrompt(prev => prev + (prev ? '\n' : '') + draftAppend);
@ -118,10 +172,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
} }
}, [draftAppend, onDraftAppendConsumed]); }, [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[]) => { const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => {
if (!instanceId || !workflowId) return; if (!instanceId || !workflowId) return;
api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, { api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, {
@ -130,10 +180,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
}).catch(err => console.warn('Failed to persist chat attachments:', err)); }).catch(err => console.warn('Failed to persist chat attachments:', err));
}, [instanceId, workflowId]); }, [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(() => { useEffect(() => {
if (!pendingAttachDsId) return; if (!pendingAttachDsId) return;
setAttachedDataSourceIds(prev => { setAttachedDataSourceIds(prev => {
@ -156,34 +202,20 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
onPendingAttachFdsConsumed?.(); onPendingAttachFdsConsumed?.();
}, [pendingAttachFdsId, onPendingAttachFdsConsumed, _persistAttachments, attachedDataSourceIds]); }, [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(() => { useEffect(() => {
if (loadedNonce === undefined) return; if (loadedNonce === undefined) return;
setAttachedFileIds([]); setAttachments([]);
setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []); setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []);
setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []); 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<number | undefined>(undefined); const _reconciledDsForNonce = useRef<number | undefined>(undefined);
const _reconciledFdsForNonce = useRef<number | undefined>(undefined); const _reconciledFdsForNonce = useRef<number | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (loadedNonce === undefined) return; if (loadedNonce === undefined) return;
if (_reconciledDsForNonce.current === loadedNonce) 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; _reconciledDsForNonce.current = loadedNonce;
const validIds = new Set(dataSources.map(d => d.id)); const validIds = new Set(dataSources.map(d => d.id));
setAttachedDataSourceIds(prev => { setAttachedDataSourceIds(prev => {
@ -217,9 +249,61 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
.catch(() => {}); .catch(() => {});
}, []); }, []);
const _resolveGroupItem = useCallback(async (item: TreeItemDrop): Promise<AttachmentItem> => {
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<boolean> => {
// 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( const _extractFileRefs = useCallback(
(text: string): string[] => { (text: string): string[] => {
const pattern = /@([\w.\-]+)/g; const pattern = /@([\w.-]+)/g;
const matched: string[] = []; const matched: string[] = [];
let match; let match;
while ((match = pattern.exec(text)) !== null) { while ((match = pattern.exec(text)) !== null) {
@ -236,17 +320,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
[files], [files],
); );
const hasFileOrSourceAttachments =
attachments.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
const _canSend = Boolean(prompt.trim()) || attachments.length > 0;
const _handleSend = useCallback(() => { const _handleSend = useCallback(() => {
const trimmed = prompt.trim(); if ((!prompt.trim() && attachments.length === 0) || isProcessing) return;
if (!trimmed || isProcessing) return; const inlineFileIds = _extractFileRefs(prompt);
const inlineFileIds = _extractFileRefs(trimmed); const attachedFileIds = attachments.flatMap(a => a.type === 'file' ? [a.id] : a.fileIds);
const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])];
const options = neutralizeActive ? { requireNeutralization: true } : undefined; const options = neutralizeActive ? { requireNeutralization: true } : undefined;
onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options); onSend(prompt.trim(), allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options);
setPrompt(''); setPrompt('');
setShowAutocomplete(false); setShowAutocomplete(false);
setAttachedFileIds([]); setAttachments([]);
}, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]); }, [prompt, isProcessing, _extractFileRefs, attachments, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]);
const _handleKeyDown = useCallback( const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
@ -264,7 +352,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
setPrompt(value); setPrompt(value);
const cursorPos = e.target.selectionStart; const cursorPos = e.target.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos); const textBeforeCursor = value.slice(0, cursorPos);
const atMatch = textBeforeCursor.match(/@([\w.\-]*)$/); const atMatch = textBeforeCursor.match(/@([\w.-]*)$/);
if (atMatch) { if (atMatch) {
setAutocompleteFilter(atMatch[1].toLowerCase()); setAutocompleteFilter(atMatch[1].toLowerCase());
setShowAutocomplete(true); setShowAutocomplete(true);
@ -291,8 +379,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
[prompt], [prompt],
); );
const _removeAttachedFile = useCallback((fileId: string) => { const _removeAttachment = useCallback((id: string) => {
setAttachedFileIds(prev => prev.filter(id => id !== fileId)); setAttachments(prev => prev.filter(a => a.id !== id));
}, []); }, []);
const _removeAttachedDataSource = useCallback((dsId: string) => { const _removeAttachedDataSource = useCallback((dsId: string) => {
@ -370,7 +458,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
? files.filter(f => f.fileName.toLowerCase().includes(autocompleteFilter)) ? 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 _horizontalPadding = isMobile ? 12 : 24;
const _controlSize = isMobile ? 38 : 40; const _controlSize = isMobile ? 38 : 40;
@ -385,9 +473,21 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
} }
}, [onPasteAsFile]); }, [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) => { const _handlePromptDragOver = useCallback((e: React.DragEvent) => {
if ( if (
e.dataTransfer.types.includes('application/tree-items') || _isTreeMimeDrag(e) ||
e.dataTransfer.types.includes('application/chat-id') || e.dataTransfer.types.includes('application/chat-id') ||
e.dataTransfer.types.includes('application/feature-source') || e.dataTransfer.types.includes('application/feature-source') ||
e.dataTransfer.types.includes('application/datasource') e.dataTransfer.types.includes('application/datasource')
@ -396,11 +496,39 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true); 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); setTreeDropOver(false);
const chatId = e.dataTransfer.getData('application/chat-id'); const chatId = e.dataTransfer.getData('application/chat-id');
@ -408,8 +536,8 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const chatLabel = e.dataTransfer.getData('text/plain'); const chatLabel = e.dataTransfer.getData('text/plain');
const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`; const refLabel = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`;
setPrompt(prev => (prev ? `${prev} ${ref}` : ref)); setPrompt(prev => (prev ? `${prev} ${refLabel}` : refLabel));
return; return;
} }
@ -431,14 +559,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
return; return;
} }
const treeItemsJson = e.dataTransfer.getData('application/tree-items'); const handled = await _ingestDataTransfer(e.dataTransfer);
if (treeItemsJson && onTreeItemsDrop) { if (handled) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const items: TreeItemDrop[] = JSON.parse(treeItemsJson); textareaRef.current?.focus();
onTreeItemsDrop(items);
} }
}, [onTreeItemsDrop, onFeatureSourceDrop, onDataSourceDrop]); }, [_ingestDataTransfer, onFeatureSourceDrop, onDataSourceDrop]);
return ( return (
<div <div
@ -452,74 +579,44 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
}} }}
onDragOver={_handlePromptDragOver} onDragOver={_handlePromptDragOver}
onDragLeave={_handlePromptDragLeave} onDragLeave={_handlePromptDragLeave}
onDrop={_handlePromptDrop} onDrop={e => void _handlePromptDrop(e)}
> >
{/* Pending uploaded files */}
{pendingFiles.length > 0 && (
<div style={{
padding: `6px ${_horizontalPadding}px`,
display: 'flex',
gap: 6,
flexWrap: 'wrap',
borderBottom: '1px solid var(--border-color, #f0f0f0)',
background: 'var(--bg-secondary, #fafafa)',
}}>
{pendingFiles.map(pf => (
<span
key={pf.fileId}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
fontWeight: 500,
border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
}}
>
{pf.itemType === 'folder' ? '📁' : '📎'} {pf.fileName.length > 25 ? pf.fileName.slice(0, 25) + '...' : pf.fileName}
{onRemovePendingFile && (
<button
onClick={() => onRemovePendingFile(pf.fileId)}
style={{
border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#e65100', padding: 0, lineHeight: 1,
}}
>
×
</button>
)}
</span>
))}
</div>
)}
{/* Attachment bar */}
{hasAttachments && ( {hasAttachments && (
<div style={{ <div style={{
padding: `6px ${_horizontalPadding}px`, padding: `8px ${_horizontalPadding}px`,
display: 'flex', display: 'flex',
gap: 6, gap: 6,
flexWrap: 'wrap', flexWrap: 'wrap',
borderBottom: '1px solid var(--border-color, #f0f0f0)', borderBottom: '1px solid var(--border-color, #f0f0f0)',
background: '#fafafa', background: '#fafafa',
}}> }}>
{attachedFileIds.map(fId => { {attachments.map(att => {
const file = files.find(f => f.id === fId); 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 ( return (
<span <span
key={fId} key={att.id}
title={isGroup ? `${att.fileIds.length} Datei(en) in dieser Gruppe` : label}
style={{ style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 12, fontSize: 11, padding: '3px 8px', borderRadius: 12, fontSize: 11,
background: '#e3f2fd', color: '#1565c0', fontWeight: 500, background: chipBg, color: chipColor, fontWeight: 500,
border: chipBorder,
}} }}
> >
📄 {file?.fileName || fId} {isGroup ? '📁' : '📄'} {label}{countBadge}
<button <button
onClick={() => _removeAttachedFile(fId)} type="button"
onClick={() => _removeAttachment(att.id)}
style={{ style={{
border: 'none', background: 'none', cursor: 'pointer', border: 'none', background: 'none', cursor: 'pointer',
fontSize: 12, color: '#1565c0', padding: 0, lineHeight: 1, fontSize: 12, color: chipColor, padding: 0, lineHeight: 1,
}} }}
> >
× ×
@ -540,6 +637,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
> >
🔗 {ds?.label || ds?.path || dsId} 🔗 {ds?.label || ds?.path || dsId}
<button <button
type="button"
onClick={() => _removeAttachedDataSource(dsId)} onClick={() => _removeAttachedDataSource(dsId)}
style={{ style={{
border: 'none', background: 'none', cursor: 'pointer', border: 'none', background: 'none', cursor: 'pointer',
@ -566,6 +664,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span> <span style={{ display: 'flex', alignItems: 'center', fontSize: 12 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId} {fds?.tableName || ''} {fds?.label || fdsId} {fds?.tableName || ''}
<button <button
type="button"
onClick={() => _toggleFeatureDataSource(fdsId)} onClick={() => _toggleFeatureDataSource(fdsId)}
style={{ style={{
border: 'none', background: 'none', cursor: 'pointer', border: 'none', background: 'none', cursor: 'pointer',
@ -580,7 +679,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
</div> </div>
)} )}
{/* Autocomplete dropdown */}
{showAutocomplete && filteredFiles.length > 0 && ( {showAutocomplete && filteredFiles.length > 0 && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
@ -598,6 +696,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
{filteredFiles.slice(0, 10).map(f => ( {filteredFiles.slice(0, 10).map(f => (
<div <div
key={f.id} key={f.id}
role="presentation"
onClick={() => _insertFileRef(f.fileName)} onClick={() => _insertFileRef(f.fileName)}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
@ -617,7 +716,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
</div> </div>
)} )}
{/* Main input row */}
<div style={{ <div style={{
padding: `8px ${_horizontalPadding}px 12px`, padding: `8px ${_horizontalPadding}px 12px`,
display: 'flex', display: 'flex',
@ -631,25 +729,35 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
onChange={_handleChange} onChange={_handleChange}
onKeyDown={_handleKeyDown} onKeyDown={_handleKeyDown}
onPaste={_handlePaste} 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} disabled={isProcessing}
style={{ style={{
flex: 1, flex: 1,
minHeight: isMobile ? 44 : 40, minHeight: isMobile ? 52 : 48,
maxHeight: 120, maxHeight: 120,
resize: 'vertical', resize: 'vertical',
padding: '10px 14px', padding: '10px 14px',
borderRadius: 8, 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, fontSize: 14,
fontFamily: 'inherit', fontFamily: 'inherit',
outline: 'none', outline: 'none',
flexBasis: isMobile ? '100%' : undefined, flexBasis: isMobile ? '100%' : undefined,
boxSizing: 'border-box',
}} }}
rows={1} rows={1}
/> />
<button <button
type="button"
onClick={onFileUploadClick} onClick={onFileUploadClick}
disabled={uploading || isProcessing} disabled={uploading || isProcessing}
title={t('Datei anhängen')} title={t('Datei anhängen')}
@ -665,8 +773,6 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
{uploading ? '...' : '+'} {uploading ? '...' : '+'}
</button> </button>
{/* Source picker removed — data sources are now attached directly from the UDB Sources/Files tabs via "send to chat" buttons */}
{onProviderSelectionChange && providerSelection && ( {onProviderSelectionChange && providerSelection && (
<ProviderMultiSelect <ProviderMultiSelect
selection={providerSelection} selection={providerSelection}
@ -678,6 +784,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
<div style={{ position: 'relative', display: 'flex', gap: 2 }}> <div style={{ position: 'relative', display: 'flex', gap: 2 }}>
<button <button
type="button"
onClick={() => setShowLangPicker(prev => !prev)} onClick={() => setShowLangPicker(prev => !prev)}
title={t('Sprache wählen')} title={t('Sprache wählen')}
style={{ style={{
@ -691,6 +798,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
{voiceLanguage.split('-')[0].toUpperCase()} {voiceLanguage.split('-')[0].toUpperCase()}
</button> </button>
<button <button
type="button"
onClick={_toggleVoice} onClick={_toggleVoice}
title={voiceActive ? t('Aufnahme stoppen') : t('Sprachaufnahme starten')} title={voiceActive ? t('Aufnahme stoppen') : t('Sprachaufnahme starten')}
style={{ style={{
@ -712,6 +820,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
{voiceCatalogLanguages.map(lang => ( {voiceCatalogLanguages.map(lang => (
<div <div
key={lang.bcp47} key={lang.bcp47}
role="presentation"
onClick={() => { onClick={() => {
setVoiceLanguage(lang.bcp47); setVoiceLanguage(lang.bcp47);
setShowLangPicker(false); setShowLangPicker(false);
@ -733,6 +842,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
</div> </div>
<button <button
type="button"
onClick={() => setNeutralizeActive(v => !v)} onClick={() => setNeutralizeActive(v => !v)}
title={neutralizeActive ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')} title={neutralizeActive ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ style={{
@ -749,6 +859,7 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
{isProcessing ? ( {isProcessing ? (
<button <button
type="button"
onClick={onStop} onClick={onStop}
style={{ style={{
padding: '10px 20px', borderRadius: 8, border: 'none', padding: '10px 20px', borderRadius: 8, border: 'none',
@ -760,12 +871,13 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
</button> </button>
) : ( ) : (
<button <button
type="button"
onClick={_handleSend} onClick={_handleSend}
disabled={!prompt.trim()} disabled={!_canSend}
style={{ style={{
padding: '10px 20px', borderRadius: 8, border: 'none', padding: '10px 20px', borderRadius: 8, border: 'none',
background: prompt.trim() ? 'var(--primary-color, #F25843)' : 'var(--color-gray-disabled, #ccc)', background: _canSend ? 'var(--primary-color, #F25843)' : 'var(--color-gray-disabled, #ccc)',
color: '#fff', cursor: prompt.trim() ? 'pointer' : 'default', fontWeight: 600, color: '#fff', cursor: _canSend ? 'pointer' : 'default', fontWeight: 600,
minWidth: isMobile ? 84 : undefined, minWidth: isMobile ? 84 : undefined,
}} }}
> >
@ -775,4 +887,4 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
</div> </div>
</div> </div>
); );
}; });

View file

@ -14,11 +14,14 @@ import { useFileOperations } from '../../../hooks/useFiles';
import { useWorkspace } from './useWorkspace'; import { useWorkspace } from './useWorkspace';
import { ChatStream } from './ChatStream'; import { ChatStream } from './ChatStream';
import { WorkspaceInput } from './WorkspaceInput'; import { WorkspaceInput } from './WorkspaceInput';
import type { WorkspaceInputHandle, TreeItemDrop } from './WorkspaceInput';
import { FilePreview } from './FilePreview'; import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog'; import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
import api from '../../../api'; import api from '../../../api';
import { collectGroupItemIds } from '../../../api/fileApi';
import type { TableGroupNode } from '../../../api/connectionApi';
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector'; import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector'; import type { ProviderSelection } from '../../../components/ProviderSelector';
import { useBilling } from '../../../hooks/useBilling'; import { useBilling } from '../../../hooks/useBilling';
@ -58,12 +61,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number)
} }
type RightTab = 'activity' | 'preview'; type RightTab = 'activity' | 'preview';
interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
interface WorkspacePageProps { interface WorkspacePageProps {
persistentInstanceId?: string; persistentInstanceId?: string;
} }
@ -85,7 +82,9 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const [rightTab, setRightTab] = useState<RightTab>('activity'); const [rightTab, setRightTab] = useState<RightTab>('activity');
const [udbTab, setUdbTab] = useState<UdbTab>('chats'); const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null); const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); const workspaceInputRef = useRef<WorkspaceInputHandle>(null);
/** Persisted grouping tree from /api/files/list — resolves dropped groups → file IDs */
const [filesListGroupTree, setFilesListGroupTree] = useState<TableGroupNode[]>([]);
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection()); const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders } = useBilling(); const { allowedProviders } = useBilling();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@ -116,6 +115,27 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
} }
}, [isMobile]); }, [isMobile]);
const _pullFilesGroupTree = useCallback(async (): Promise<TableGroupNode[]> => {
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(() => { useEffect(() => {
if (autoStartHandled.current || !instanceId || workspace.isProcessing) return; if (autoStartHandled.current || !instanceId || workspace.isProcessing) return;
const prompt = searchParams.get('prompt'); const prompt = searchParams.get('prompt');
@ -132,23 +152,77 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
} }
}, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]); }, [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 _uploadAndAttach = useCallback(async (file: File) => {
const result = await fileOps.handleFileUpload(file, undefined, instanceId); const result = await fileOps.handleFileUpload(file, undefined, instanceId);
if (result.success && result.fileData) { if (result.success && result.fileData) {
const data = result.fileData.file || result.fileData; const data = result.fileData.file || result.fileData;
if (data?.id) { if (data?.id) {
setPendingFiles(prev => [...prev, { fileId: data.id, fileName: data.fileName || file.name }]); workspaceInputRef.current?.attachFileIds([data.id]);
} }
workspace.refreshFiles(); workspace.refreshFiles();
} }
}, [fileOps, workspace, instanceId]); }, [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) => { const _handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
dragCounterRef.current++; dragCounterRef.current++;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true); if (_isCenterDropInteresting(e)) setIsDragOver(true);
}, []); }, [_isCenterDropInteresting]);
const _handleDragLeave = useCallback((e: React.DragEvent) => { const _handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -158,9 +232,11 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
}, []); }, []);
const _handleDragOver = useCallback((e: React.DragEvent) => { const _handleDragOver = useCallback((e: React.DragEvent) => {
if (!_isCenterDropInteresting(e)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}, []); e.dataTransfer.dropEffect = 'copy';
}, [_isCenterDropInteresting]);
const _handleDrop = useCallback(async (e: React.DragEvent) => { const _handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -168,27 +244,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
dragCounterRef.current = 0; dragCounterRef.current = 0;
setIsDragOver(false); setIsDragOver(false);
const chatId = e.dataTransfer.getData('application/chat-id'); await _consumeDataTransferFilesOrChat(e.dataTransfer);
if (chatId) { }, [_consumeDataTransferFilesOrChat]);
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]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
@ -197,22 +254,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
} }
}, [_uploadAndAttach]); }, [_uploadAndAttach]);
const _handleRemovePendingFile = useCallback((fileId: string) => { const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => {
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId)); void workspaceInputRef.current?.attachTreeItems(
}, []); items.map(i => ({ id: i.id, type: i.type, name: i.name })),
);
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];
});
}, []); }, []);
if (!instanceId) { if (!instanceId) {
@ -279,20 +324,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
workspace.refreshFeatureDataSources(); workspace.refreshFeatureDataSources();
}, [workspace]); }, [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<string>(''); const [pendingAttachFdsId, setPendingAttachFdsId] = useState<string>('');
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => { const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
@ -497,7 +528,7 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
fontSize: 16, fontWeight: 600, color: 'var(--primary-color, #F25843)', fontSize: 16, fontWeight: 600, color: 'var(--primary-color, #F25843)',
pointerEvents: 'none', pointerEvents: 'none',
}}> }}>
Dateien hier ablegen {t('Dateien oder Gruppen hier ablegen')}
</div> </div>
)} )}
<ChatStream <ChatStream
@ -510,26 +541,23 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)} onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
/> />
<WorkspaceInput <WorkspaceInput
ref={workspaceInputRef}
instanceId={instanceId} instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => { onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders); const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options); workspace.sendMessage(prompt, fileIds || [], dataSourceIds, resolvedProviders, featureDataSourceIds, options);
setPendingFiles([]);
}} }}
isProcessing={workspace.isProcessing} isProcessing={workspace.isProcessing}
onStop={workspace.stopProcessing} onStop={workspace.stopProcessing}
files={workspace.files} files={workspace.files}
dataSources={workspace.dataSources} dataSources={workspace.dataSources}
featureDataSources={workspace.featureDataSources} featureDataSources={workspace.featureDataSources}
pendingFiles={pendingFiles} resolveTreeItemsToFileIds={_resolveTreeItemsToFileIds}
onRemovePendingFile={_handleRemovePendingFile}
onFileUploadClick={() => fileInputRef.current?.click()} onFileUploadClick={() => fileInputRef.current?.click()}
uploading={fileOps.uploadingFile} uploading={fileOps.uploadingFile}
providerSelection={providerSelection} providerSelection={providerSelection}
onProviderSelectionChange={setProviderSelection} onProviderSelectionChange={setProviderSelection}
isMobile={isMobile} isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onFeatureSourceDrop={_handleSendToChat_FeatureSource} onFeatureSourceDrop={_handleSendToChat_FeatureSource}
onDataSourceDrop={_handleDataSourceDrop} onDataSourceDrop={_handleDataSourceDrop}
pendingAttachDsId={pendingAttachDsId} pendingAttachDsId={pendingAttachDsId}

View file

@ -35,7 +35,6 @@ export interface WorkspaceFile {
mimeType: string; mimeType: string;
fileSize: number; fileSize: number;
tags?: string[]; tags?: string[];
folderId?: string;
status?: string; status?: string;
description?: string; description?: string;
featureInstanceId?: string; featureInstanceId?: string;
@ -44,12 +43,6 @@ export interface WorkspaceFile {
neutralize: boolean; neutralize: boolean;
} }
export interface WorkspaceFolder {
id: string;
name: string;
parentId?: string;
}
export interface DataSource { export interface DataSource {
id: string; id: string;
connectionId: string; connectionId: string;
@ -101,7 +94,6 @@ interface UseWorkspaceReturn {
loadWorkflow: (workflowId: string) => void; loadWorkflow: (workflowId: string) => void;
resetToNew: () => void; resetToNew: () => void;
files: WorkspaceFile[]; files: WorkspaceFile[];
folders: WorkspaceFolder[];
dataSources: DataSource[]; dataSources: DataSource[];
featureDataSources: FeatureDataSource[]; featureDataSources: FeatureDataSource[];
refreshFeatureDataSources: () => void; refreshFeatureDataSources: () => void;
@ -113,7 +105,6 @@ interface UseWorkspaceReturn {
workflowId: string | null; workflowId: string | null;
workflowVersion: number; workflowVersion: number;
refreshFiles: () => void; refreshFiles: () => void;
refreshFolders: () => void;
refreshDataSources: () => void; refreshDataSources: () => void;
dataSourceAccesses: DataSourceAccessEvent[]; dataSourceAccesses: DataSourceAccessEvent[];
/** /**
@ -135,7 +126,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [files, setFiles] = useState<WorkspaceFile[]>([]); const [files, setFiles] = useState<WorkspaceFile[]>([]);
const [folders, setFolders] = useState<WorkspaceFolder[]>([]);
const [dataSources, setDataSources] = useState<DataSource[]>([]); const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [featureDataSources, setFeatureDataSources] = useState<FeatureDataSource[]>([]); const [featureDataSources, setFeatureDataSources] = useState<FeatureDataSource[]>([]);
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null); const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
@ -156,13 +146,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
.catch(err => console.error('Failed to load workspace files:', err)); .catch(err => console.error('Failed to load workspace files:', err));
}, [instanceId]); }, [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(() => { const refreshDataSources = useCallback(() => {
if (!instanceId) return; if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/datasources`) api.get(`/api/workspace/${instanceId}/datasources`)
@ -180,10 +163,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
useEffect(() => { useEffect(() => {
if (!instanceId) return; if (!instanceId) return;
refreshFiles(); refreshFiles();
refreshFolders();
refreshDataSources(); refreshDataSources();
refreshFeatureDataSources(); refreshFeatureDataSources();
}, [instanceId, refreshFiles, refreshFolders, refreshDataSources, refreshFeatureDataSources]); }, [instanceId, refreshFiles, refreshDataSources, refreshFeatureDataSources]);
const loadWorkflow = useCallback((wfId: string) => { const loadWorkflow = useCallback((wfId: string) => {
if (!instanceId || !wfId) return; if (!instanceId || !wfId) return;
@ -511,7 +493,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
loadWorkflow, loadWorkflow,
resetToNew, resetToNew,
files, files,
folders,
dataSources, dataSources,
featureDataSources, featureDataSources,
refreshFeatureDataSources, refreshFeatureDataSources,
@ -523,7 +504,6 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
workflowId, workflowId,
workflowVersion, workflowVersion,
refreshFiles, refreshFiles,
refreshFolders,
refreshDataSources, refreshDataSources,
dataSourceAccesses, dataSourceAccesses,
loadedAttachedDataSourceIds, loadedAttachedDataSourceIds,

View file

@ -2,7 +2,6 @@
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.test.json" }
] ]
} }