Compare commits
17 commits
28951a7d22
...
3d580a5fca
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d580a5fca | |||
| 1d2d247273 | |||
|
|
992c0472c6 | ||
|
|
02bf4020d7 | ||
|
|
c7e94aea79 | ||
| 7c05cb0dd7 | |||
| e7a79a3484 | |||
|
|
ad96c6d861 | ||
|
|
70459d57e3 | ||
|
|
8cecf3b320 | ||
| aff9dcb7bd | |||
| 31586d62c1 | |||
| c8e9304801 | |||
| b61544d8b1 | |||
|
|
26958d1e16 | ||
|
|
a0c2323fe6 | ||
|
|
3f80d6d434 |
56 changed files with 5449 additions and 1679 deletions
|
|
@ -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.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export interface PortField {
|
||||||
description: string | Record<string, string>;
|
description: string | Record<string, string>;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
enumValues?: string[] | null;
|
enumValues?: string[] | null;
|
||||||
|
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
|
||||||
|
recommended?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PortSchema {
|
export interface PortSchema {
|
||||||
|
|
@ -85,11 +87,19 @@ export interface SystemVariable {
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single form field type with its canonical port primitive. Delivered by GET /node-types. */
|
||||||
|
export interface FormFieldType {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
portType: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeTypesResponse {
|
export interface NodeTypesResponse {
|
||||||
nodeTypes: NodeType[];
|
nodeTypes: NodeType[];
|
||||||
categories: NodeTypeCategory[];
|
categories: NodeTypeCategory[];
|
||||||
portTypeCatalog?: Record<string, PortSchema>;
|
portTypeCatalog?: Record<string, PortSchema>;
|
||||||
systemVariables?: Record<string, SystemVariable>;
|
systemVariables?: Record<string, SystemVariable>;
|
||||||
|
formFieldTypes?: FormFieldType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Automation2GraphNode {
|
export interface Automation2GraphNode {
|
||||||
|
|
@ -144,6 +154,8 @@ export interface Automation2Workflow {
|
||||||
label: string;
|
label: string;
|
||||||
graph: Automation2Graph;
|
graph: Automation2Graph;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
/** Target feature instance for execution data scope (NULL for templates) */
|
||||||
|
targetFeatureInstanceId?: string | null;
|
||||||
/** Entry points (Starts) — how this workflow may be invoked */
|
/** Entry points (Starts) — how this workflow may be invoked */
|
||||||
invocations?: WorkflowEntryPoint[];
|
invocations?: WorkflowEntryPoint[];
|
||||||
/** Enriched: run count */
|
/** Enriched: run count */
|
||||||
|
|
@ -277,12 +289,14 @@ export async function fetchNodeTypes(
|
||||||
const categories = data?.categories ?? [];
|
const categories = data?.categories ?? [];
|
||||||
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
|
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
|
||||||
const systemVariables = data?.systemVariables ?? undefined;
|
const systemVariables = data?.systemVariables ?? undefined;
|
||||||
|
const formFieldTypes = data?.formFieldTypes ?? undefined;
|
||||||
console.log(
|
console.log(
|
||||||
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
|
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
|
||||||
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
|
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
|
||||||
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars`
|
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` +
|
||||||
|
`${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes`
|
||||||
);
|
);
|
||||||
return { nodeTypes, categories, portTypeCatalog, systemVariables };
|
return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpstreamPathEntry {
|
export interface UpstreamPathEntry {
|
||||||
|
|
@ -412,7 +426,12 @@ export async function fetchWorkflow(
|
||||||
export async function createWorkflow(
|
export async function createWorkflow(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
|
body: {
|
||||||
|
label: string;
|
||||||
|
graph: Automation2Graph;
|
||||||
|
invocations?: WorkflowEntryPoint[];
|
||||||
|
targetFeatureInstanceId?: string | null;
|
||||||
|
}
|
||||||
): Promise<Automation2Workflow> {
|
): Promise<Automation2Workflow> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/workflows/${instanceId}/workflows`,
|
url: `/api/workflows/${instanceId}/workflows`,
|
||||||
|
|
@ -431,6 +450,7 @@ export async function updateWorkflow(
|
||||||
invocations?: WorkflowEntryPoint[];
|
invocations?: WorkflowEntryPoint[];
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
notifyOnFailure?: boolean;
|
notifyOnFailure?: boolean;
|
||||||
|
targetFeatureInstanceId?: string | null;
|
||||||
}
|
}
|
||||||
): Promise<Automation2Workflow> {
|
): Promise<Automation2Workflow> {
|
||||||
return await request({
|
return await request({
|
||||||
|
|
@ -986,3 +1006,95 @@ export async function loadClickupListTasksForDropdown(
|
||||||
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUTOMATION WORKSPACE API (user-facing run workspace)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface WorkspaceRun {
|
||||||
|
id: string;
|
||||||
|
workflowId: string;
|
||||||
|
workflowLabel?: string;
|
||||||
|
status: string;
|
||||||
|
startedAt?: number;
|
||||||
|
completedAt?: number;
|
||||||
|
ownerId?: string;
|
||||||
|
mandateId?: string;
|
||||||
|
mandateLabel?: string;
|
||||||
|
targetFeatureInstanceId?: string;
|
||||||
|
targetInstanceLabel?: string;
|
||||||
|
costTokens?: number;
|
||||||
|
costCredits?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceRunDetail {
|
||||||
|
run: WorkspaceRun & { nodeOutputs?: Record<string, unknown> };
|
||||||
|
workflow: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
targetFeatureInstanceId?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
tags?: string[];
|
||||||
|
} | null;
|
||||||
|
steps: Array<{
|
||||||
|
id: string;
|
||||||
|
runId: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeType: string;
|
||||||
|
status: string;
|
||||||
|
inputSnapshot?: Record<string, unknown>;
|
||||||
|
output?: Record<string, unknown>;
|
||||||
|
inputFiles?: Array<{ id: string; fileName?: string }>;
|
||||||
|
outputFiles?: Array<{ id: string; fileName?: string }>;
|
||||||
|
error?: string;
|
||||||
|
startedAt?: number;
|
||||||
|
completedAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
tokensUsed?: number;
|
||||||
|
retryCount?: number;
|
||||||
|
}>;
|
||||||
|
files: Array<{
|
||||||
|
id: string;
|
||||||
|
fileName?: string;
|
||||||
|
contentType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
}>;
|
||||||
|
unassignedFiles?: Array<{
|
||||||
|
id: string;
|
||||||
|
fileName?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkspaceRuns(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params: {
|
||||||
|
scope?: 'mine' | 'mandate';
|
||||||
|
status?: string;
|
||||||
|
targetInstanceId?: string;
|
||||||
|
workflowId?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{ runs: WorkspaceRun[]; total: number }> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.scope) query.set('scope', params.scope);
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId);
|
||||||
|
if (params.workflowId) query.set('workflowId', params.workflowId);
|
||||||
|
if (params.limit) query.set('limit', String(params.limit));
|
||||||
|
if (params.offset) query.set('offset', String(params.offset));
|
||||||
|
const qs = query.toString();
|
||||||
|
const url = `/api/automations/runs${qs ? `?${qs}` : ''}`;
|
||||||
|
const resp = await request({ url, method: 'get' });
|
||||||
|
return resp as { runs: WorkspaceRun[]; total: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkspaceRunDetail(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
runId: string,
|
||||||
|
): Promise<WorkspaceRunDetail> {
|
||||||
|
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'get' });
|
||||||
|
return resp as WorkspaceRunDetail;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
520
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal file
520
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal 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!]}
|
||||||
|
{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;
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||||
import type { ApiRequestFunction, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
import type { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||||
|
|
||||||
export interface Automation2DataFlowContextValue {
|
export interface Automation2DataFlowContextValue {
|
||||||
currentNodeId: string;
|
currentNodeId: string;
|
||||||
|
|
@ -17,6 +17,8 @@ export interface Automation2DataFlowContextValue {
|
||||||
language: string;
|
language: string;
|
||||||
portTypeCatalog: Record<string, PortSchema>;
|
portTypeCatalog: Record<string, PortSchema>;
|
||||||
systemVariables: Record<string, SystemVariable>;
|
systemVariables: Record<string, SystemVariable>;
|
||||||
|
/** Canonical form field types from the API — maps UI type id to portType primitive. */
|
||||||
|
formFieldTypes: FormFieldType[];
|
||||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||||
getAvailableSourceIds: () => string[];
|
getAvailableSourceIds: () => string[];
|
||||||
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
|
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
|
||||||
|
|
@ -41,6 +43,7 @@ interface Automation2DataFlowProviderProps {
|
||||||
language: string;
|
language: string;
|
||||||
portTypeCatalog?: Record<string, PortSchema>;
|
portTypeCatalog?: Record<string, PortSchema>;
|
||||||
systemVariables?: Record<string, SystemVariable>;
|
systemVariables?: Record<string, SystemVariable>;
|
||||||
|
formFieldTypes?: FormFieldType[];
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
request?: ApiRequestFunction;
|
request?: ApiRequestFunction;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -55,12 +58,18 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
language,
|
language,
|
||||||
portTypeCatalog = {},
|
portTypeCatalog = {},
|
||||||
systemVariables = {},
|
systemVariables = {},
|
||||||
|
formFieldTypes = [],
|
||||||
instanceId,
|
instanceId,
|
||||||
request,
|
request,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
const formTypeToPort: Record<string, string> = Object.fromEntries(
|
||||||
|
formFieldTypes.map((f) => [f.id, f.portType])
|
||||||
|
);
|
||||||
|
const resolvePortType = (rawType: string): string => formTypeToPort[rawType] ?? rawType;
|
||||||
|
|
||||||
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
|
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
|
||||||
const raw = node.parameters?.[parameterKey];
|
const raw = node.parameters?.[parameterKey];
|
||||||
if (!Array.isArray(raw)) return null;
|
if (!Array.isArray(raw)) return null;
|
||||||
|
|
@ -72,8 +81,8 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
const lab = rec.label;
|
const lab = rec.label;
|
||||||
const desc =
|
const desc =
|
||||||
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
|
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
|
||||||
const ftype = typeof rec.type === 'string' ? rec.type : 'str';
|
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
|
||||||
if (ftype === 'group' && Array.isArray(rec.fields)) {
|
if (rawType === 'group' && Array.isArray(rec.fields)) {
|
||||||
for (const sub of rec.fields as Record<string, unknown>[]) {
|
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||||
if (!sub || typeof sub.name !== 'string') continue;
|
if (!sub || typeof sub.name !== 'string') continue;
|
||||||
const sl = sub.label;
|
const sl = sub.label;
|
||||||
|
|
@ -85,7 +94,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
: '';
|
: '';
|
||||||
fields.push({
|
fields.push({
|
||||||
name: `${rec.name}.${sub.name}`,
|
name: `${rec.name}.${sub.name}`,
|
||||||
type: typeof sub.type === 'string' ? sub.type : 'str',
|
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
|
||||||
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
|
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
|
||||||
required: Boolean(sub.required),
|
required: Boolean(sub.required),
|
||||||
});
|
});
|
||||||
|
|
@ -94,7 +103,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
}
|
}
|
||||||
fields.push({
|
fields.push({
|
||||||
name: rec.name,
|
name: rec.name,
|
||||||
type: ftype,
|
type: resolvePortType(rawType),
|
||||||
description: (desc && desc.trim()) || rec.name,
|
description: (desc && desc.trim()) || rec.name,
|
||||||
required: Boolean(rec.required),
|
required: Boolean(rec.required),
|
||||||
});
|
});
|
||||||
|
|
@ -110,6 +119,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
language,
|
language,
|
||||||
portTypeCatalog,
|
portTypeCatalog,
|
||||||
systemVariables,
|
systemVariables,
|
||||||
|
formFieldTypes,
|
||||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||||
n.title ?? n.label ?? n.type ?? n.id,
|
n.title ?? n.label ?? n.type ?? n.id,
|
||||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||||
|
|
@ -117,7 +127,7 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
|
||||||
request,
|
request,
|
||||||
parseGraphDefinedSchema,
|
parseGraphDefinedSchema,
|
||||||
};
|
};
|
||||||
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, instanceId, request]);
|
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, instanceId, request]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Automation2DataFlowContext.Provider value={value}>
|
<Automation2DataFlowContext.Provider value={value}>
|
||||||
|
|
|
||||||
|
|
@ -1725,6 +1725,35 @@
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Type-mismatch warning badge (⚠) — shown instead of hiding incompatible fields. */
|
||||||
|
.dataPickerMismatchBadge {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--color-warning, #f59e0b);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recommended pick: subtle highlight on the row */
|
||||||
|
.dataPickerLeafRecommended {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Empfohlen" pill shown on recommended entries */
|
||||||
|
.dataPickerRecommendedPill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
background: var(--color-primary-light, #dbeafe);
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
/* "iterieren" affordance — visually distinct (subtle accent), readable on
|
/* "iterieren" affordance — visually distinct (subtle accent), readable on
|
||||||
* the picker's white background and on the leaf's blue hover background. */
|
* the picker's white background and on the leaf's blue hover background. */
|
||||||
.dataPickerIterateBtn {
|
.dataPickerIterateBtn {
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { useFeatureStore } from '../../../stores/featureStore';
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
const LOG = '[Automation2]';
|
||||||
|
|
||||||
|
|
@ -98,6 +99,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||||
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
||||||
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
||||||
|
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
|
|
@ -133,6 +135,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||||
const [versionLoading, setVersionLoading] = useState(false);
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
|
|
||||||
|
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
||||||
|
const featureStore = useFeatureStore();
|
||||||
|
const targetInstanceOptions = useMemo(() => {
|
||||||
|
const allInstances = featureStore.getAllInstances();
|
||||||
|
return allInstances
|
||||||
|
.filter((inst) => inst.mandateId === mandateId || !mandateId)
|
||||||
|
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
|
||||||
|
}, [featureStore, mandateId]);
|
||||||
|
|
||||||
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
||||||
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
||||||
});
|
});
|
||||||
|
|
@ -297,7 +308,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (currentWorkflowId) {
|
if (currentWorkflowId) {
|
||||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
|
||||||
setExecuteResult(_buildSaveResult());
|
setExecuteResult(_buildSaveResult());
|
||||||
} else {
|
} else {
|
||||||
const label = await promptInput(t('Workflow-Name:'), {
|
const label = await promptInput(t('Workflow-Name:'), {
|
||||||
|
|
@ -313,6 +324,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
label: label.trim() || t('Neuer Workflow'),
|
label: label.trim() || t('Neuer Workflow'),
|
||||||
graph,
|
graph,
|
||||||
invocations,
|
invocations,
|
||||||
|
targetFeatureInstanceId,
|
||||||
});
|
});
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
if (created.invocations?.length) setInvocations(created.invocations);
|
if (created.invocations?.length) setInvocations(created.invocations);
|
||||||
|
|
@ -324,7 +336,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
|
||||||
|
|
||||||
const handleLoad = useCallback(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
|
|
@ -335,6 +347,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} else {
|
} else {
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
||||||
}
|
}
|
||||||
|
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
|
||||||
setWorkflows((prev) => {
|
setWorkflows((prev) => {
|
||||||
const idx = prev.findIndex((w) => w.id === workflowId);
|
const idx = prev.findIndex((w) => w.id === workflowId);
|
||||||
if (idx === -1) return [...prev, wf];
|
if (idx === -1) return [...prev, wf];
|
||||||
|
|
@ -447,6 +460,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setRegistryCatalog(data.portTypeCatalog as never);
|
setRegistryCatalog(data.portTypeCatalog as never);
|
||||||
}
|
}
|
||||||
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
||||||
|
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setNodeTypes([]);
|
setNodeTypes([]);
|
||||||
|
|
@ -661,6 +675,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
[request, instanceId, handleFromApiGraph]
|
[request, instanceId, handleFromApiGraph]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
|
||||||
|
setTargetFeatureInstanceId(newTargetId || null);
|
||||||
|
if (currentWorkflowId && newTargetId) {
|
||||||
|
try {
|
||||||
|
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.error(`${LOG} target instance update failed`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [request, instanceId, currentWorkflowId]);
|
||||||
|
|
||||||
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
|
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
|
||||||
try {
|
try {
|
||||||
await updateWorkflow(request, instanceId, workflowId, { label: newName });
|
await updateWorkflow(request, instanceId, workflowId, { label: newName });
|
||||||
|
|
@ -836,6 +861,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onAutoLayout={handleAutoLayout}
|
onAutoLayout={handleAutoLayout}
|
||||||
verboseSchema={verboseSchema}
|
verboseSchema={verboseSchema}
|
||||||
onVerboseSchemaChange={setVerboseSchema}
|
onVerboseSchemaChange={setVerboseSchema}
|
||||||
|
targetFeatureInstanceId={targetFeatureInstanceId}
|
||||||
|
onTargetInstanceChange={handleTargetInstanceChange}
|
||||||
|
targetInstanceOptions={targetInstanceOptions}
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|
@ -878,6 +906,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
language={language}
|
language={language}
|
||||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||||
systemVariables={systemVariables as Record<string, never>}
|
systemVariables={systemVariables as Record<string, never>}
|
||||||
|
formFieldTypes={formFieldTypes}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
request={request}
|
request={request}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ import styles from './Automation2FlowEditor.module.css';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { getUserDataCache } from '../../../utils/userCache';
|
import { getUserDataCache } from '../../../utils/userCache';
|
||||||
|
|
||||||
|
interface TargetInstanceOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CanvasHeaderProps {
|
interface CanvasHeaderProps {
|
||||||
workflows: Automation2Workflow[];
|
workflows: Automation2Workflow[];
|
||||||
currentWorkflowId: string | null;
|
currentWorkflowId: string | null;
|
||||||
|
|
@ -45,6 +50,9 @@ interface CanvasHeaderProps {
|
||||||
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||||
verboseSchema?: boolean;
|
verboseSchema?: boolean;
|
||||||
onVerboseSchemaChange?: (next: boolean) => void;
|
onVerboseSchemaChange?: (next: boolean) => void;
|
||||||
|
targetFeatureInstanceId?: string | null;
|
||||||
|
onTargetInstanceChange?: (instanceId: string) => void;
|
||||||
|
targetInstanceOptions?: TargetInstanceOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||||
|
|
@ -84,6 +92,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
onAutoLayout,
|
onAutoLayout,
|
||||||
verboseSchema,
|
verboseSchema,
|
||||||
onVerboseSchemaChange,
|
onVerboseSchemaChange,
|
||||||
|
targetFeatureInstanceId,
|
||||||
|
onTargetInstanceChange,
|
||||||
|
targetInstanceOptions,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||||
|
|
@ -209,6 +220,21 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
<FaCog />
|
<FaCog />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
|
||||||
|
<select
|
||||||
|
className={styles.canvasHeaderWorkflowSelect}
|
||||||
|
value={targetFeatureInstanceId ?? ''}
|
||||||
|
onChange={(e) => onTargetInstanceChange(e.target.value)}
|
||||||
|
aria-label={t('Ziel-Instanz')}
|
||||||
|
title={t('Ziel-Instanz für Daten-Scope')}
|
||||||
|
style={{ maxWidth: 200, fontSize: '0.8rem' }}
|
||||||
|
>
|
||||||
|
<option value="">{t('Ziel-Instanz wählen…')}</option>
|
||||||
|
{targetInstanceOptions.map((opt) => (
|
||||||
|
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
|
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
||||||
'filterExpression',
|
'filterExpression',
|
||||||
'attachmentBuilder',
|
'attachmentBuilder',
|
||||||
'json',
|
'json',
|
||||||
|
'modelMultiSelect',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,16 @@ import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const ctx = useAutomation2DataFlow();
|
||||||
|
const fieldTypeOptions = ctx?.formFieldTypes?.length
|
||||||
|
? ctx.formFieldTypes
|
||||||
|
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
|
||||||
const fields = (params.fields as FormField[]) ?? [];
|
const fields = (params.fields as FormField[]) ?? [];
|
||||||
|
|
||||||
const moveField = (fromIndex: number, toIndex: number) => {
|
const moveField = (fromIndex: number, toIndex: number) => {
|
||||||
|
|
@ -88,8 +93,8 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, upda
|
||||||
}}
|
}}
|
||||||
style={{ width: 'auto', minWidth: 90 }}
|
style={{ width: 'auto', minWidth: 90 }}
|
||||||
>
|
>
|
||||||
{FORM_FIELD_TYPES.map(ft => (
|
{fieldTypeOptions.map((ft) => (
|
||||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label className={styles.formFieldRequiredLabel}>
|
<label className={styles.formFieldRequiredLabel}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* ContextBuilderRenderer — multi-select context binding for AI nodes.
|
||||||
|
*
|
||||||
|
* Renders a list of DataRef entries (each pointing to an upstream node's output
|
||||||
|
* path). On execution the backend serialises each ref, joins them with double
|
||||||
|
* newlines and prepends the result to the AI prompt.
|
||||||
|
*
|
||||||
|
* Stored value shape:
|
||||||
|
* [ { type: "ref", nodeId: "...", path: [...], expectedType: "..." }, … ]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { DataPicker } from '../shared/DataPicker';
|
||||||
|
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
|
||||||
|
import type { FieldRendererProps } from './index';
|
||||||
|
|
||||||
|
function isRefEntry(v: unknown): v is DataRef {
|
||||||
|
return isRef(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRefList(raw: unknown): DataRef[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
if (Array.isArray(raw)) return raw.filter(isRefEntry);
|
||||||
|
if (isRefEntry(raw)) return [raw];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIP_STYLE: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '3px 6px 3px 10px',
|
||||||
|
background: '#eaf6e8',
|
||||||
|
border: '1px solid #5cb85c',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const REMOVE_BTN: React.CSSProperties = {
|
||||||
|
padding: '0 5px',
|
||||||
|
border: '1px solid #5cb85c',
|
||||||
|
borderRadius: 3,
|
||||||
|
background: '#fff',
|
||||||
|
color: '#3c763d',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 11,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const dataFlow = useAutomation2DataFlow();
|
||||||
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||||
|
const dragIndex = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
const entries = toRefList(value);
|
||||||
|
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
|
||||||
|
const hasSources = sourceIds.some((id) => {
|
||||||
|
const n = dataFlow?.nodes.find((x) => x.id === id);
|
||||||
|
return n?.type !== 'trigger.manual';
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRefLabel = (ref: DataRef): string => {
|
||||||
|
const nodeLabel =
|
||||||
|
dataFlow?.getNodeLabel(
|
||||||
|
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
|
||||||
|
) ?? ref.nodeId;
|
||||||
|
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
|
||||||
|
return pathStr ? `${nodeLabel} → ${pathStr}` : nodeLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addRef = (picked: DataRef | SystemVarRef) => {
|
||||||
|
if (!isRefEntry(picked)) return;
|
||||||
|
const alreadyIn = entries.some(
|
||||||
|
(e) => e.nodeId === picked.nodeId && e.path.join('.') === picked.path.join('.'),
|
||||||
|
);
|
||||||
|
if (!alreadyIn) {
|
||||||
|
onChange([...entries, picked]);
|
||||||
|
}
|
||||||
|
setPickerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeRef = (index: number) => {
|
||||||
|
const next = entries.filter((_, i) => i !== index);
|
||||||
|
onChange(next.length ? next : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveRef = (fromIndex: number, toIndex: number) => {
|
||||||
|
if (fromIndex === toIndex) return;
|
||||||
|
const next = [...entries];
|
||||||
|
const [moved] = next.splice(fromIndex, 1);
|
||||||
|
next.splice(toIndex, 0, moved);
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>
|
||||||
|
{param.description || param.name}
|
||||||
|
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
{entries.map((ref, i) => (
|
||||||
|
<div
|
||||||
|
key={`${ref.nodeId}-${ref.path.join('.')}`}
|
||||||
|
style={{ ...CHIP_STYLE, cursor: 'grab' }}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => { dragIndex.current = i; }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); }}
|
||||||
|
onDrop={() => {
|
||||||
|
if (dragIndex.current != null) moveRef(dragIndex.current, i);
|
||||||
|
dragIndex.current = null;
|
||||||
|
}}
|
||||||
|
onDragEnd={() => { dragIndex.current = null; }}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1, color: '#2d6a2d' }}>
|
||||||
|
{getRefLabel(ref)}
|
||||||
|
</span>
|
||||||
|
<button type="button" style={REMOVE_BTN} onClick={() => removeRef(i)} title={t('Entfernen')}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: '#f8f8f8',
|
||||||
|
border: '1px dashed #ccc',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#888',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Noch keine Quellen gewählt — wähle Daten aus vorherigen Schritten.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
disabled={!hasSources}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid #1c5fb5`,
|
||||||
|
background: hasSources ? '#fff' : '#f5f5f5',
|
||||||
|
color: hasSources ? '#1c5fb5' : '#999',
|
||||||
|
cursor: hasSources ? 'pointer' : 'not-allowed',
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasSources ? t('+ Datenquelle hinzufügen …') : t('Keine vorherigen Nodes verfügbar')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{dataFlow && (
|
||||||
|
<DataPicker
|
||||||
|
open={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onPick={addRef}
|
||||||
|
availableSourceIds={sourceIds}
|
||||||
|
nodes={dataFlow.nodes}
|
||||||
|
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
|
||||||
|
getNodeLabel={dataFlow.getNodeLabel}
|
||||||
|
expectedParamType={param.type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* TemplateTextarea — Freitext mit eingebetteten {{nodeId.path}} Tokens.
|
||||||
|
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { FieldRendererProps } from './index';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
import { DataPicker } from '../shared/DataPicker';
|
||||||
|
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
|
||||||
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||||
|
|
||||||
|
function _refToTemplateToken(ref: DataRef): string {
|
||||||
|
const pathSegs = (ref.path ?? []).map((p) => String(p));
|
||||||
|
if (pathSegs.length === 0) {
|
||||||
|
return `{{${ref.nodeId}}}`;
|
||||||
|
}
|
||||||
|
return `{{${ref.nodeId}.${pathSegs.join('.')}}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _insertAtCursor(
|
||||||
|
text: string,
|
||||||
|
insert: string,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
): { next: string; caret: number } {
|
||||||
|
const next = text.slice(0, start) + insert + text.slice(end);
|
||||||
|
const caret = start + insert.length;
|
||||||
|
return { next, caret };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseTokensInTemplate(
|
||||||
|
template: string,
|
||||||
|
nodes: Array<{ id: string; title?: string }>,
|
||||||
|
getNodeLabel: (n: { id: string; title?: string }) => string,
|
||||||
|
): Array<{ raw: string; label: string }> {
|
||||||
|
const out: Array<{ raw: string; label: string }> = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g');
|
||||||
|
while ((m = re.exec(template)) !== null) {
|
||||||
|
const inner = m[1].trim();
|
||||||
|
if (seen.has(inner)) continue;
|
||||||
|
seen.add(inner);
|
||||||
|
const parts = inner.split('.');
|
||||||
|
const nodeId = parts[0];
|
||||||
|
if (!nodeId) continue;
|
||||||
|
const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg));
|
||||||
|
const ref: DataRef = { type: 'ref', nodeId, path };
|
||||||
|
const label = formatRefLabel(ref, nodes, (id) =>
|
||||||
|
getNodeLabel(nodes.find((n) => n.id === id) ?? { id }),
|
||||||
|
);
|
||||||
|
out.push({ raw: m[0], label });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const dataFlow = useAutomation2DataFlow();
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
|
||||||
|
|
||||||
|
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
|
||||||
|
const hasSources = sourceIds.some((id) => {
|
||||||
|
const n = dataFlow?.nodes.find((x) => x.id === id);
|
||||||
|
return n?.type !== 'trigger.manual';
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenLegend = useMemo(() => {
|
||||||
|
if (!dataFlow || !strVal.includes('{{')) return [];
|
||||||
|
return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel);
|
||||||
|
}, [strVal, dataFlow]);
|
||||||
|
|
||||||
|
const handlePick = useCallback(
|
||||||
|
(picked: DataRef | SystemVarRef) => {
|
||||||
|
if (isSystemVar(picked)) {
|
||||||
|
setPickerOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRef(picked)) {
|
||||||
|
setPickerOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = _refToTemplateToken(picked);
|
||||||
|
const el = textareaRef.current;
|
||||||
|
const start = el?.selectionStart ?? strVal.length;
|
||||||
|
const end = el?.selectionEnd ?? strVal.length;
|
||||||
|
const { next, caret } = _insertAtCursor(strVal, token, start, end);
|
||||||
|
onChange(next);
|
||||||
|
setPickerOpen(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(caret, caret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange, strVal],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.startsInput}
|
||||||
|
disabled={!hasSources}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
title={hasSources ? t('Variable aus vorherigem Node einfügen') : t('Keine vorherigen Nodes verfügbar')}
|
||||||
|
>
|
||||||
|
{t('Variable einfügen…')}
|
||||||
|
</button>
|
||||||
|
{!hasSources && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Keine vorherigen Nodes verfügbar')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={strVal}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={param.name}
|
||||||
|
rows={6}
|
||||||
|
spellCheck={false}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
resize: 'vertical',
|
||||||
|
fontFamily: 'ui-monospace, monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
minHeight: 120,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tokenLegend.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-secondary)' }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 2 }}>{t('Eingebundene Variablen')}</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
|
{tokenLegend.map((row) => (
|
||||||
|
<li key={row.raw} style={{ marginBottom: 2 }}>
|
||||||
|
<code style={{ fontSize: 10 }}>{row.raw}</code>
|
||||||
|
{' — '}
|
||||||
|
{row.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dataFlow && (
|
||||||
|
<DataPicker
|
||||||
|
open={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onPick={handlePick}
|
||||||
|
availableSourceIds={sourceIds}
|
||||||
|
nodes={dataFlow.nodes}
|
||||||
|
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
|
||||||
|
getNodeLabel={dataFlow.getNodeLabel}
|
||||||
|
expectedParamType={param.type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@ import type { ComponentType } from 'react';
|
||||||
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
import type { NodeTypeParameter } from '../../../../api/workflowApi';
|
||||||
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
import type { ApiRequestFunction } from '../../../../api/workflowApi';
|
||||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
|
||||||
export interface FieldRendererProps {
|
export interface FieldRendererProps {
|
||||||
param: NodeTypeParameter;
|
param: NodeTypeParameter;
|
||||||
|
|
@ -27,12 +28,14 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
|
||||||
import { toApiGraph } from '../shared/graphUtils';
|
import { toApiGraph } from '../shared/graphUtils';
|
||||||
import { postUpstreamPaths } from '../../../../api/workflowApi';
|
import { postUpstreamPaths } from '../../../../api/workflowApi';
|
||||||
import type { CanvasNode } from '../../editor/FlowCanvas';
|
import type { CanvasNode } from '../../editor/FlowCanvas';
|
||||||
import { DataRefRenderer } from './DataRefRenderer';
|
import { DataRefRenderer } from './DataRefRenderer';
|
||||||
|
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
|
||||||
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
import { FeatureInstancePicker } from './FeatureInstancePicker';
|
||||||
|
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
|
||||||
|
import { getApiBaseUrl } from '../../../../../config/config';
|
||||||
|
|
||||||
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
|
@ -533,6 +536,10 @@ const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }
|
||||||
|
|
||||||
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const ctx = useAutomation2DataFlow();
|
||||||
|
const fieldTypeOptions = ctx?.formFieldTypes?.length
|
||||||
|
? ctx.formFieldTypes
|
||||||
|
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
|
||||||
const fields = Array.isArray(value) ? value : [];
|
const fields = Array.isArray(value) ? value : [];
|
||||||
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]);
|
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]);
|
||||||
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
|
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
|
||||||
|
|
@ -541,28 +548,65 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
|
||||||
onChange(next);
|
onChange(next);
|
||||||
};
|
};
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
|
||||||
|
fontSize: 12, boxSizing: 'border-box', background: '#fff',
|
||||||
|
};
|
||||||
|
const selectStyle: React.CSSProperties = { ...inputStyle };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>{param.description || param.name}</label>
|
||||||
{fields.map((f: Record<string, unknown>, i: number) => (
|
{fields.map((f: Record<string, unknown>, i: number) => (
|
||||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
|
<div key={i} style={{ background: '#f9f9f9', border: '1px solid #e0e0e0', borderRadius: 6, padding: '8px 10px', marginBottom: 6 }}>
|
||||||
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
{/* Row 1: Bezeichnung + delete */}
|
||||||
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
|
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'center' }}>
|
||||||
{FORM_FIELD_TYPES.map(ft => (
|
<input
|
||||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
type="text"
|
||||||
|
placeholder={t('Bezeichnung (Anzeigename)')}
|
||||||
|
value={String(f.label ?? '')}
|
||||||
|
onChange={(e) => updateField(i, 'label', e.target.value)}
|
||||||
|
style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeField(i)}
|
||||||
|
title={t('Feld entfernen')}
|
||||||
|
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
{/* Row 2: Name + Typ + Pflicht */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="z.B. customerName"
|
||||||
|
value={String(f.name ?? '')}
|
||||||
|
onChange={(e) => updateField(i, 'name', e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div>
|
||||||
|
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={selectStyle}>
|
||||||
|
{fieldTypeOptions.map((ft) => (
|
||||||
|
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||||
))}
|
))}
|
||||||
<option value="group">{t('Gruppe')}</option>
|
<option value="group">{t('Gruppe')}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
|
</div>
|
||||||
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
|
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', paddingBottom: 5, whiteSpace: 'nowrap' }}>
|
||||||
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
|
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} />
|
||||||
|
Pflicht
|
||||||
</label>
|
</label>
|
||||||
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
|
</div>
|
||||||
{String(f.type) === 'group' && (
|
{String(f.type) === 'group' && (
|
||||||
<div style={{ width: '100%', marginTop: 6, marginLeft: 8, borderLeft: '2px solid #ddd', paddingLeft: 8 }}>
|
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
|
||||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>{t('Unterfelder')}</div>
|
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
|
||||||
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => (
|
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => (
|
||||||
<div key={j} style={{ display: 'flex', gap: 4, marginBottom: 4, flexWrap: 'wrap' }}>
|
<div key={j} style={{ background: '#fff', border: '1px solid #e8e8e8', borderRadius: 4, padding: '6px 8px', marginBottom: 4 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('Name')}
|
placeholder={t('Name')}
|
||||||
|
|
@ -572,7 +616,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
nextFields[j] = { ...sub, name: e.target.value };
|
nextFields[j] = { ...sub, name: e.target.value };
|
||||||
updateField(i, 'fields', nextFields);
|
updateField(i, 'fields', nextFields);
|
||||||
}}
|
}}
|
||||||
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
style={{ ...inputStyle, flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={String(sub.type ?? 'text')}
|
value={String(sub.type ?? 'text')}
|
||||||
|
|
@ -581,10 +625,10 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
nextFields[j] = { ...sub, type: e.target.value };
|
nextFields[j] = { ...sub, type: e.target.value };
|
||||||
updateField(i, 'fields', nextFields);
|
updateField(i, 'fields', nextFields);
|
||||||
}}
|
}}
|
||||||
style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}
|
style={{ ...selectStyle, flex: 1 }}
|
||||||
>
|
>
|
||||||
{FORM_FIELD_TYPES.map(ft => (
|
{fieldTypeOptions.map((ft) => (
|
||||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
|
|
@ -593,10 +637,9 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
|
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
|
||||||
updateField(i, 'fields', nextFields);
|
updateField(i, 'fields', nextFields);
|
||||||
}}
|
}}
|
||||||
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
|
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
|
||||||
>
|
>×</button>
|
||||||
×
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
|
|
@ -605,15 +648,21 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
|
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
|
||||||
updateField(i, 'fields', nextFields);
|
updateField(i, 'fields', nextFields);
|
||||||
}}
|
}}
|
||||||
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 11 }}
|
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
|
||||||
>
|
>
|
||||||
{t('Unterfeld hinzufügen')}
|
+ {t('Unterfeld hinzufügen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addField}
|
||||||
|
style={{ width: '100%', padding: '6px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 12, background: '#fff', color: '#555' }}
|
||||||
|
>
|
||||||
|
+ {t('Feld hinzufügen')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -736,6 +785,113 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ModelMultiSelect: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [models, setModels] = React.useState<Array<{ displayName: string; connectorType?: string }>>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const selected: string[] = Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${getApiBaseUrl()}/api/system/ai-models`, { credentials: 'include' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const items = (data?.models ?? []) as Array<{ displayName: string; connectorType?: string }>;
|
||||||
|
setModels(items);
|
||||||
|
})
|
||||||
|
.catch(() => { if (!cancelled) setModels([]); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _toggle = (name: string) => {
|
||||||
|
const next = selected.includes(name)
|
||||||
|
? selected.filter((v) => v !== name)
|
||||||
|
: [...selected, name];
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _removeTag = (name: string) => {
|
||||||
|
onChange(selected.filter((v) => v !== name));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||||
|
<div
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 32,
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
background: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected.length === 0 && (
|
||||||
|
<span style={{ color: '#999', fontSize: 12 }}>{t('Alle erlaubten Modelle')}</span>
|
||||||
|
)}
|
||||||
|
{selected.map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
background: 'var(--primary-color, #2563eb)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '1px 6px',
|
||||||
|
fontSize: 11,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
<span
|
||||||
|
onClick={(e) => { e.stopPropagation(); _removeTag(name); }}
|
||||||
|
style={{ cursor: 'pointer', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div style={{ border: '1px solid #ddd', borderRadius: 4, marginTop: 4, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 4 }}>
|
||||||
|
{loading && <div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Lade Modelle...')}</div>}
|
||||||
|
{!loading && models.length === 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
|
||||||
|
)}
|
||||||
|
{models.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.displayName}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 4px', fontSize: 12, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(m.displayName)}
|
||||||
|
onChange={() => _toggle(m.displayName)}
|
||||||
|
/>
|
||||||
|
<span>{m.displayName}</span>
|
||||||
|
{m.connectorType && (
|
||||||
|
<span style={{ fontSize: 10, color: '#888' }}>({m.connectorType})</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Registry
|
// Registry
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -743,6 +899,7 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
|
||||||
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
text: TextInput,
|
text: TextInput,
|
||||||
textarea: TextareaInput,
|
textarea: TextareaInput,
|
||||||
|
templateTextarea: TemplateTextareaRenderer,
|
||||||
number: NumberInput,
|
number: NumberInput,
|
||||||
checkbox: CheckboxInput,
|
checkbox: CheckboxInput,
|
||||||
date: DateInput,
|
date: DateInput,
|
||||||
|
|
@ -750,10 +907,12 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
|
||||||
email: TextInput,
|
email: TextInput,
|
||||||
select: SelectInput,
|
select: SelectInput,
|
||||||
multiselect: MultiSelectInput,
|
multiselect: MultiSelectInput,
|
||||||
|
modelMultiSelect: ModelMultiSelect,
|
||||||
json: JsonEditor,
|
json: JsonEditor,
|
||||||
file: TextInput,
|
file: TextInput,
|
||||||
hidden: HiddenInput,
|
hidden: HiddenInput,
|
||||||
dataRef: DataRefRenderer,
|
dataRef: DataRefRenderer,
|
||||||
|
contextBuilder: ContextBuilderRenderer,
|
||||||
userConnection: ConnectionPicker,
|
userConnection: ConnectionPicker,
|
||||||
featureInstance: FeatureInstancePicker,
|
featureInstance: FeatureInstancePicker,
|
||||||
sharepointFolder: SharepointPathPicker,
|
sharepointFolder: SharepointPathPicker,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ interface PickablePath {
|
||||||
/** True iff this path produces `List[X]` and the consumer expects `X` —
|
/** True iff this path produces `List[X]` and the consumer expects `X` —
|
||||||
* picking with iterate=true appends the wildcard segment. */
|
* picking with iterate=true appends the wildcard segment. */
|
||||||
iterable?: boolean;
|
iterable?: boolean;
|
||||||
|
/** Annotated after strict-filter pass: type exists but doesn't match the expected param type. */
|
||||||
|
typeMismatch?: boolean;
|
||||||
|
/** Surfaced at the top of the list as the most common / recommended pick. */
|
||||||
|
recommended?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _LIST_INNER_RE = /^List\[(.+)\]$/;
|
const _LIST_INNER_RE = /^List\[(.+)\]$/;
|
||||||
|
|
@ -47,10 +51,22 @@ function _buildPathsFromSchema(
|
||||||
): PickablePath[] {
|
): PickablePath[] {
|
||||||
if (!schema || !schema.fields || depth > 8) return [];
|
if (!schema || !schema.fields || depth > 8) return [];
|
||||||
const result: PickablePath[] = [];
|
const result: PickablePath[] = [];
|
||||||
|
|
||||||
|
// For form schemas (kind=fromGraph), expose the whole `payload` object as a
|
||||||
|
// top-level pickable entry so the user can pass the entire form at once.
|
||||||
|
if (depth === 0 && schema.name?.startsWith('FormPayload')) {
|
||||||
|
result.push({
|
||||||
|
path: ['payload'],
|
||||||
|
label: 'Gesamtes Formular',
|
||||||
|
type: 'object',
|
||||||
|
recommended: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const field of schema.fields) {
|
for (const field of schema.fields) {
|
||||||
const fieldPath = [...basePath, field.name];
|
const fieldPath = [...basePath, field.name];
|
||||||
const label = fieldPath.map(String).join(' → ');
|
const label = fieldPath.map(String).join(' → ');
|
||||||
result.push({ path: fieldPath, label, type: field.type });
|
result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false });
|
||||||
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
|
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
|
||||||
const inner = m?.[1]?.trim();
|
const inner = m?.[1]?.trim();
|
||||||
if (inner && catalog[inner]) {
|
if (inner && catalog[inner]) {
|
||||||
|
|
@ -78,7 +94,9 @@ function _markIterableCandidates(paths: PickablePath[], expectedParamType?: stri
|
||||||
function _deriveFormPortSchemaFromParams(
|
function _deriveFormPortSchemaFromParams(
|
||||||
node: { parameters?: Record<string, unknown> },
|
node: { parameters?: Record<string, unknown> },
|
||||||
paramKey: string,
|
paramKey: string,
|
||||||
|
formTypeToPort: Record<string, string> = {},
|
||||||
): PortSchema | undefined {
|
): PortSchema | undefined {
|
||||||
|
const resolvePortType = (rawType: string) => formTypeToPort[rawType] ?? rawType;
|
||||||
const raw = node.parameters?.[paramKey];
|
const raw = node.parameters?.[paramKey];
|
||||||
if (!Array.isArray(raw)) return undefined;
|
if (!Array.isArray(raw)) return undefined;
|
||||||
const fields: Array<{ name: string; type: string; description: string | Record<string, string>; required: boolean }> = [];
|
const fields: Array<{ name: string; type: string; description: string | Record<string, string>; required: boolean }> = [];
|
||||||
|
|
@ -90,8 +108,8 @@ function _deriveFormPortSchemaFromParams(
|
||||||
let description: string | Record<string, string> = rec.name;
|
let description: string | Record<string, string> = rec.name;
|
||||||
if (typeof lab === 'string') description = lab;
|
if (typeof lab === 'string') description = lab;
|
||||||
else if (lab && typeof lab === 'object') description = lab as Record<string, string>;
|
else if (lab && typeof lab === 'object') description = lab as Record<string, string>;
|
||||||
const ftype = typeof rec.type === 'string' ? rec.type : 'str';
|
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
|
||||||
if (ftype === 'group' && Array.isArray(rec.fields)) {
|
if (rawType === 'group' && Array.isArray(rec.fields)) {
|
||||||
for (const sub of rec.fields as Record<string, unknown>[]) {
|
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||||
if (!sub || typeof sub.name !== 'string') continue;
|
if (!sub || typeof sub.name !== 'string') continue;
|
||||||
const sl = sub.label;
|
const sl = sub.label;
|
||||||
|
|
@ -100,7 +118,7 @@ function _deriveFormPortSchemaFromParams(
|
||||||
else if (sl && typeof sl === 'object') sdesc = sl as Record<string, string>;
|
else if (sl && typeof sl === 'object') sdesc = sl as Record<string, string>;
|
||||||
fields.push({
|
fields.push({
|
||||||
name: `${rec.name}.${sub.name}`,
|
name: `${rec.name}.${sub.name}`,
|
||||||
type: typeof sub.type === 'string' ? sub.type : 'str',
|
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
|
||||||
description: sdesc,
|
description: sdesc,
|
||||||
required: Boolean(sub.required),
|
required: Boolean(sub.required),
|
||||||
});
|
});
|
||||||
|
|
@ -109,7 +127,7 @@ function _deriveFormPortSchemaFromParams(
|
||||||
}
|
}
|
||||||
fields.push({
|
fields.push({
|
||||||
name: rec.name,
|
name: rec.name,
|
||||||
type: ftype,
|
type: resolvePortType(rawType),
|
||||||
description,
|
description,
|
||||||
required: Boolean(rec.required),
|
required: Boolean(rec.required),
|
||||||
});
|
});
|
||||||
|
|
@ -151,6 +169,7 @@ function _resolveSchemaForNode(
|
||||||
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
|
||||||
catalog: Record<string, PortSchema>,
|
catalog: Record<string, PortSchema>,
|
||||||
visited: Set<string> = new Set(),
|
visited: Set<string> = new Set(),
|
||||||
|
formTypeToPort: Record<string, string> = {},
|
||||||
): PortSchema | undefined {
|
): PortSchema | undefined {
|
||||||
if (visited.has(nodeId)) return undefined;
|
if (visited.has(nodeId)) return undefined;
|
||||||
visited.add(nodeId);
|
visited.add(nodeId);
|
||||||
|
|
@ -170,10 +189,10 @@ function _resolveSchemaForNode(
|
||||||
const schemaSpec = port0.schema;
|
const schemaSpec = port0.schema;
|
||||||
if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') {
|
if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') {
|
||||||
const paramKey = schemaSpec.parameter ?? 'fields';
|
const paramKey = schemaSpec.parameter ?? 'fields';
|
||||||
return _deriveFormPortSchemaFromParams(node, paramKey);
|
return _deriveFormPortSchemaFromParams(node, paramKey, formTypeToPort);
|
||||||
}
|
}
|
||||||
if (port0.dynamic && port0.deriveFrom) {
|
if (port0.dynamic && port0.deriveFrom) {
|
||||||
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom);
|
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom, formTypeToPort);
|
||||||
}
|
}
|
||||||
if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') {
|
if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') {
|
||||||
return catalog[schemaSpec];
|
return catalog[schemaSpec];
|
||||||
|
|
@ -182,7 +201,7 @@ function _resolveSchemaForNode(
|
||||||
// Transit: follow the incoming connection to find the real producer
|
// Transit: follow the incoming connection to find the real producer
|
||||||
const incoming = connections.find((c) => c.target === nodeId);
|
const incoming = connections.find((c) => c.target === nodeId);
|
||||||
if (!incoming) return undefined;
|
if (!incoming) return undefined;
|
||||||
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited);
|
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited, formTypeToPort);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
|
|
@ -228,6 +247,9 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
const catalog = ctx?.portTypeCatalog ?? {};
|
const catalog = ctx?.portTypeCatalog ?? {};
|
||||||
const systemVars = ctx?.systemVariables ?? {};
|
const systemVars = ctx?.systemVariables ?? {};
|
||||||
const nodeTypes = ctx?.nodeTypes ?? [];
|
const nodeTypes = ctx?.nodeTypes ?? [];
|
||||||
|
const formTypeToPort: Record<string, string> = Object.fromEntries(
|
||||||
|
(ctx?.formFieldTypes ?? []).map((f) => [f.id, f.portType])
|
||||||
|
);
|
||||||
|
|
||||||
const toggleExpand = (nodeId: string) => {
|
const toggleExpand = (nodeId: string) => {
|
||||||
setExpandedNodes((prev) => {
|
setExpandedNodes((prev) => {
|
||||||
|
|
@ -320,15 +342,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
<div key={loopId} style={{ marginBottom: 6 }}>
|
<div key={loopId} style={{ marginBottom: 6 }}>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div>
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div>
|
||||||
{loopPaths.map((p, i) => {
|
{loopPaths.map((p, i) => {
|
||||||
const compat = expectedParamType && p.type
|
const mismatch =
|
||||||
? isCompatible(p.type, expectedParamType)
|
Boolean(expectedParamType) &&
|
||||||
: 'ok';
|
Boolean(p.type) &&
|
||||||
|
isCompatible(p.type!, expectedParamType!) === 'mismatch';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${loopId}-${p.path.join('.')}-${i}`}
|
key={`${loopId}-${p.path.join('.')}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.dataPickerLeaf}
|
className={styles.dataPickerLeaf}
|
||||||
style={{ opacity: compat === 'mismatch' ? 0.45 : 1 }}
|
|
||||||
onClick={() => handlePick(loopId, p.path, p.type)}
|
onClick={() => handlePick(loopId, p.path, p.type)}
|
||||||
>
|
>
|
||||||
{p.label}
|
{p.label}
|
||||||
|
|
@ -337,6 +359,14 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
({p.type})
|
({p.type})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{mismatch && (
|
||||||
|
<span
|
||||||
|
className={styles.dataPickerMismatchBadge}
|
||||||
|
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -386,7 +416,11 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
}
|
}
|
||||||
return filteredIds.map((nodeId) => {
|
return filteredIds.map((nodeId) => {
|
||||||
const node = nodes.find((n) => n.id === nodeId);
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
const label = node ? getNodeLabel(node) : nodeId;
|
// User-defined step title (or node-type label as fallback)
|
||||||
|
const stepTitle = node ? getNodeLabel(node) : nodeId;
|
||||||
|
const nodeTypeDef = node?.type ? nodeTypes.find((nt) => nt.id === node.type) : undefined;
|
||||||
|
// Human-readable type label (e.g. "Formular", "Web-Recherche")
|
||||||
|
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
|
||||||
const isExpanded = expandedNodes.has(nodeId);
|
const isExpanded = expandedNodes.has(nodeId);
|
||||||
|
|
||||||
const resolvedSchema = _resolveSchemaForNode(
|
const resolvedSchema = _resolveSchemaForNode(
|
||||||
|
|
@ -395,6 +429,8 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
connections,
|
connections,
|
||||||
catalog,
|
catalog,
|
||||||
|
new Set(),
|
||||||
|
formTypeToPort,
|
||||||
);
|
);
|
||||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
|
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
|
||||||
const annotated = _markIterableCandidates(
|
const annotated = _markIterableCandidates(
|
||||||
|
|
@ -403,13 +439,21 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
|
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
|
||||||
expectedParamType,
|
expectedParamType,
|
||||||
);
|
);
|
||||||
const paths = strictFilter && expectedParamType
|
// Always show all paths; mark mismatches as a visual warning instead of hiding them.
|
||||||
? annotated.filter((p) => {
|
// Recommended entries bubble to the top.
|
||||||
if (p.iterable) return true;
|
const markedPaths = annotated.map((p) => ({
|
||||||
if (!p.type) return false;
|
...p,
|
||||||
return isCompatible(p.type, expectedParamType) !== 'mismatch';
|
typeMismatch:
|
||||||
})
|
strictFilter &&
|
||||||
: annotated;
|
Boolean(expectedParamType) &&
|
||||||
|
Boolean(p.type) &&
|
||||||
|
!p.iterable &&
|
||||||
|
isCompatible(p.type!, expectedParamType!) === 'mismatch',
|
||||||
|
}));
|
||||||
|
const paths = [
|
||||||
|
...markedPaths.filter((p) => p.recommended),
|
||||||
|
...markedPaths.filter((p) => !p.recommended),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
<div key={nodeId} className={styles.dataPickerNodeSection}>
|
||||||
|
|
@ -419,10 +463,10 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
onClick={() => toggleExpand(nodeId)}
|
onClick={() => toggleExpand(nodeId)}
|
||||||
>
|
>
|
||||||
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
|
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
|
||||||
<span className={styles.dataPickerNodeLabel}>{label}</span>
|
<span className={styles.dataPickerNodeLabel}>{stepTitle}</span>
|
||||||
{resolvedSchema && (
|
{typeLabel && (
|
||||||
<span className={styles.dataPickerNodeSchemaHint}>
|
<span className={styles.dataPickerNodeSchemaHint}>
|
||||||
({resolvedSchema.name})
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -430,12 +474,10 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
<div className={styles.dataPickerTree}>
|
<div className={styles.dataPickerTree}>
|
||||||
{paths.length === 0 && (
|
{paths.length === 0 && (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
|
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
|
||||||
{t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')}
|
{t('(keine Felder verfügbar)')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{paths.map((p, i) => {
|
{paths.map((p, i) => {
|
||||||
const compat =
|
|
||||||
expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok';
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${p.path.join('.')}-${i}`}
|
key={`${p.path.join('.')}-${i}`}
|
||||||
|
|
@ -443,16 +485,29 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.dataPickerLeaf}
|
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
|
||||||
style={{ opacity: compat === 'mismatch' && !p.iterable ? 0.45 : 1, flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
onClick={() => handlePick(nodeId, p.path, p.type)}
|
onClick={() => handlePick(nodeId, p.path, p.type)}
|
||||||
>
|
>
|
||||||
{p.label}
|
{p.label}
|
||||||
|
{p.recommended && (
|
||||||
|
<span className={styles.dataPickerRecommendedPill}>
|
||||||
|
{t('Empfohlen')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{p.type && (
|
{p.type && (
|
||||||
<span className={styles.dataPickerLeafType}>
|
<span className={styles.dataPickerLeafType}>
|
||||||
({p.type})
|
({p.type})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{p.typeMismatch && (
|
||||||
|
<span
|
||||||
|
className={styles.dataPickerMismatchBadge}
|
||||||
|
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
|
||||||
|
>
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{p.iterable && (
|
{p.iterable && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,37 @@ function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||||||
|
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
|
||||||
|
{ path: ['currentItem'], pathLabel: 'currentItem' },
|
||||||
|
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
|
||||||
|
{ path: ['count'], pathLabel: 'count' },
|
||||||
|
];
|
||||||
|
if (preview && typeof preview === 'object') {
|
||||||
|
const ci = (preview as Record<string, unknown>).currentItem;
|
||||||
|
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
|
||||||
|
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
|
||||||
|
paths.push(...buildPickablePaths(v, ['currentItem', k]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
|
||||||
|
const paths = buildPickablePaths(preview);
|
||||||
|
if (preview && typeof preview === 'object') {
|
||||||
|
const rd = (preview as Record<string, unknown>).responseData;
|
||||||
|
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
|
||||||
|
for (const k of Object.keys(rd as Record<string, unknown>)) {
|
||||||
|
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
|
||||||
|
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
export function pickPathsForNode(
|
export function pickPathsForNode(
|
||||||
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
|
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
|
||||||
preview: unknown,
|
preview: unknown,
|
||||||
|
|
@ -113,6 +144,12 @@ export function pickPathsForNode(
|
||||||
if (nt.startsWith('clickup.')) {
|
if (nt.startsWith('clickup.')) {
|
||||||
return buildClickUpOutputPaths(preview);
|
return buildClickUpOutputPaths(preview);
|
||||||
}
|
}
|
||||||
|
if (nt === 'flow.loop') {
|
||||||
|
return buildLoopCurrentItemPaths(preview);
|
||||||
|
}
|
||||||
|
if (nt === 'ai.prompt') {
|
||||||
|
return buildAiPromptPaths(preview);
|
||||||
|
}
|
||||||
return buildPickablePaths(preview);
|
return buildPickablePaths(preview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,26 @@ export function createRef(nodeId: string, path: (string | number)[] = [], expect
|
||||||
return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) };
|
return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Structural type compatibility (best-effort; same as gateway soft rules). */
|
/**
|
||||||
|
* Structural type compatibility using the canonical type vocabulary: str / int / float / bool / Any.
|
||||||
|
* All node parameters and form field schemas must use these types (no `string`, `number`, `boolean`
|
||||||
|
* aliases) so no alias-mapping is needed here.
|
||||||
|
*
|
||||||
|
* `Any` as expected type accepts everything.
|
||||||
|
* `Any`, `object`, or `dict` as produced type coerces to `str` (backend serializes via json.dumps).
|
||||||
|
*/
|
||||||
export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' {
|
export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' {
|
||||||
if (!expectedType || !producedType) return 'ok';
|
if (!expectedType || !producedType) return 'ok';
|
||||||
if (producedType === expectedType) return 'ok';
|
if (producedType === expectedType) return 'ok';
|
||||||
if (expectedType === 'Any' || producedType === 'Any') return 'ok';
|
// Any-expected: accept all sources
|
||||||
|
if (expectedType === 'Any') return 'ok';
|
||||||
|
// Any-produced: compatible with everything (coerce where needed)
|
||||||
|
if (producedType === 'Any') return 'coerce';
|
||||||
|
// Numeric coercion
|
||||||
if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce';
|
if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce';
|
||||||
if (expectedType === 'int' && producedType === 'str') return 'coerce';
|
if (expectedType === 'int' && producedType === 'str') return 'coerce';
|
||||||
|
// Object/dict → str: backend serializes to JSON text
|
||||||
|
if (expectedType === 'str' && (producedType === 'object' || producedType === 'dict')) return 'coerce';
|
||||||
return 'mismatch';
|
return 'mismatch';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,32 @@ export function buildNodeOutputPreview(
|
||||||
return _buildSchemaPreview(port0.schema);
|
return _buildSchemaPreview(port0.schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _buildEmailItemPreview(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
from: { emailAddress: { address: 'sender@example.com', name: 'Sender' } },
|
||||||
|
subject: '...',
|
||||||
|
body: { contentType: 'HTML', content: '...' },
|
||||||
|
receivedDateTime: '2026-01-01T00:00:00Z',
|
||||||
|
toRecipients: [],
|
||||||
|
hasAttachments: false,
|
||||||
|
id: '...',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildAiResponseDataPreview(params: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
if (params.resultType !== 'json') return null;
|
||||||
|
const prompt = String(params.aiPrompt || params.prompt || '');
|
||||||
|
if (!prompt) return null;
|
||||||
|
const fields: Record<string, unknown> = {};
|
||||||
|
const re = /["']?(\w+)["']?\s*:\s*(?:true|false|"[^"]*"|'[^']*'|\d+|boolean|string|number|bool)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(prompt)) !== null) {
|
||||||
|
const f = m[1];
|
||||||
|
if (f && !['type', 'value', 'key'].includes(f)) fields[f] = '...';
|
||||||
|
}
|
||||||
|
return Object.keys(fields).length > 0 ? fields : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Build full nodeOutputsPreview map from graph */
|
/** Build full nodeOutputsPreview map from graph */
|
||||||
export function buildNodeOutputsPreview(
|
export function buildNodeOutputsPreview(
|
||||||
nodes: CanvasNode[],
|
nodes: CanvasNode[],
|
||||||
|
|
@ -92,5 +118,32 @@ export function buildNodeOutputsPreview(
|
||||||
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
|
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.id in (nodeOutputsFromRun ?? {})) continue;
|
||||||
|
|
||||||
|
if (n.type === 'flow.loop') {
|
||||||
|
const items = n.parameters?.items;
|
||||||
|
if (items && typeof items === 'object' && (items as { type?: string }).type === 'ref') {
|
||||||
|
const ref = items as { nodeId: string; path?: (string | number)[] };
|
||||||
|
const sourceNode = nodes.find((sn) => sn.id === ref.nodeId);
|
||||||
|
const sourceDef = sourceNode ? typeMap.get(sourceNode.type) : undefined;
|
||||||
|
const sourceSchema = sourceDef?.outputPorts?.[0]?.schema;
|
||||||
|
if (sourceSchema === 'EmailList') {
|
||||||
|
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
|
||||||
|
result[n.id] = { ...existing, currentItem: _buildEmailItemPreview() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n.type === 'ai.prompt' && n.parameters) {
|
||||||
|
const rdPreview = _buildAiResponseDataPreview(n.parameters);
|
||||||
|
if (rdPreview) {
|
||||||
|
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
|
||||||
|
result[n.id] = { ...existing, responseData: rdPreview };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,110 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
|
||||||
/** Function type for resolving localized labels */
|
/** Function type for resolving localized labels */
|
||||||
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
||||||
|
|
||||||
/** Build an HTML accept attribute from an upload node config's allowedTypes array. */
|
/** Extension → MIME when the browser leaves ``File.type`` empty (common on Windows). */
|
||||||
export function getAcceptStringFromConfig(
|
const _EXT_TO_MIME: Record<string, string> = {
|
||||||
config: Record<string, unknown>
|
'.pdf': 'application/pdf',
|
||||||
): string {
|
'.doc': 'application/msword',
|
||||||
const types = config.allowedTypes;
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
if (!Array.isArray(types) || types.length === 0) return '*';
|
'.xls': 'application/vnd.ms-excel',
|
||||||
return types.join(',');
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'.ppt': 'application/vnd.ms-powerpoint',
|
||||||
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.zip': 'application/zip',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.jpe': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
function _extensionVariants(ext: string): string[] {
|
||||||
|
const e = ext.toLowerCase();
|
||||||
|
if (e === '.jpeg' || e === '.jpe') return ['.jpeg', '.jpe', '.jpg'];
|
||||||
|
if (e === '.jpg') return ['.jpg', '.jpeg', '.jpe'];
|
||||||
|
return [e];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if ``file`` satisfies an HTML-style ``accept`` string (extensions, MIME types, ``image/*``).
|
||||||
|
* - ``*`` or empty → allow all
|
||||||
|
* - Normalizes gateway multiselect tokens ``pdf`` → ``.pdf`` (via {@link getAcceptStringFromConfig})
|
||||||
|
* - Infers MIME from extension when ``file.type`` is empty
|
||||||
|
*/
|
||||||
|
export function fileMatchesAccept(file: File, accept: string): boolean {
|
||||||
|
const trimmed = (accept ?? '').trim();
|
||||||
|
if (!trimmed || trimmed === '*') return true;
|
||||||
|
const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (parts.length === 0) return true;
|
||||||
|
|
||||||
|
const name = file.name ?? '';
|
||||||
|
const ext =
|
||||||
|
name.includes('.') && !name.endsWith('.')
|
||||||
|
? '.' + (name.split('.').pop() ?? '').toLowerCase()
|
||||||
|
: '';
|
||||||
|
let mime = (file.type ?? '').trim().toLowerCase();
|
||||||
|
if (!mime && ext && _EXT_TO_MIME[ext]) {
|
||||||
|
mime = _EXT_TO_MIME[ext];
|
||||||
|
}
|
||||||
|
const extVariants = ext ? _extensionVariants(ext) : [];
|
||||||
|
|
||||||
|
for (const rawPart of parts) {
|
||||||
|
for (const p of rawPart.split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||||
|
const pp = p.toLowerCase();
|
||||||
|
if (pp === '*') return true;
|
||||||
|
if (pp.startsWith('.')) {
|
||||||
|
if (extVariants.some((e) => e === pp)) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pp.endsWith('/*')) {
|
||||||
|
const prefix = pp.slice(0, -2);
|
||||||
|
if (mime.startsWith(prefix + '/')) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pp.includes('/')) {
|
||||||
|
if (mime === pp) return true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Bare token left from legacy configs, e.g. "pdf" without dot
|
||||||
|
if (/^[a-z0-9]{2,16}$/.test(pp)) {
|
||||||
|
const dotted = '.' + pp;
|
||||||
|
if (extVariants.includes(dotted)) return true;
|
||||||
|
if (extVariants.some((e) => _extensionVariants(e).includes(dotted))) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a combined accept list from ``allowedTypes`` (multiselect: pdf, docx, …) and optional
|
||||||
|
* manual ``accept`` string on the node.
|
||||||
|
*/
|
||||||
|
export function getAcceptStringFromConfig(config: Record<string, unknown>): string {
|
||||||
|
const fromParam =
|
||||||
|
typeof config.accept === 'string' && config.accept.trim() ? config.accept.trim() : '';
|
||||||
|
const types = config.allowedTypes;
|
||||||
|
let fromAllowed = '';
|
||||||
|
if (Array.isArray(types) && types.length > 0) {
|
||||||
|
fromAllowed = types
|
||||||
|
.map((t) => {
|
||||||
|
const s = String(t).trim().toLowerCase();
|
||||||
|
if (!s) return '';
|
||||||
|
if (s === '*') return '*';
|
||||||
|
if (s.includes('/') || s.endsWith('/*')) return s;
|
||||||
|
if (s.startsWith('.')) return s;
|
||||||
|
return `.${s.replace(/^\.+/, '')}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
if (fromParam && fromAllowed) return `${fromParam},${fromAllowed}`;
|
||||||
|
if (fromParam) return fromParam;
|
||||||
|
if (fromAllowed) return fromAllowed;
|
||||||
|
return '*';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import type { FormField } from '../shared/types';
|
import type { FormField } from '../shared/types';
|
||||||
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -28,6 +29,10 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
|
||||||
|
|
||||||
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const ctx = useAutomation2DataFlow();
|
||||||
|
const fieldTypeOptions = ctx?.formFieldTypes?.length
|
||||||
|
? ctx.formFieldTypes
|
||||||
|
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
|
||||||
const fields = useMemo(() => _parseFields(params, t), [params, t]);
|
const fields = useMemo(() => _parseFields(params, t), [params, t]);
|
||||||
|
|
||||||
const setFields = (next: FormField[]) => {
|
const setFields = (next: FormField[]) => {
|
||||||
|
|
@ -73,8 +78,8 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
setFields(next);
|
setFields(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{FORM_FIELD_TYPES.map(ft => (
|
{fieldTypeOptions.map((ft) => (
|
||||||
<option key={ft} value={ft}>{t(FORM_FIELD_TYPE_LABELS[ft])}</option>
|
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
339
src/components/FormGenerator/GroupingManager/GroupRow.module.css
Normal file
339
src/components/FormGenerator/GroupingManager/GroupRow.module.css
Normal 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);
|
||||||
|
}
|
||||||
374
src/components/FormGenerator/GroupingManager/GroupRow.tsx
Normal file
374
src/components/FormGenerator/GroupingManager/GroupRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,17 +10,21 @@ export interface Tab {
|
||||||
export interface TabsProps {
|
export interface TabsProps {
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
defaultTabId?: string;
|
defaultTabId?: string;
|
||||||
|
/** Controlled active tab. When provided, internal state is ignored. */
|
||||||
|
activeTabId?: string;
|
||||||
onTabChange?: (tabId: string) => void;
|
onTabChange?: (tabId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Tabs({ tabs, defaultTabId, onTabChange, className = '' }: TabsProps) {
|
export function Tabs({ tabs, defaultTabId, activeTabId: controlledTabId, onTabChange, className = '' }: TabsProps) {
|
||||||
const [activeTabId, setActiveTabId] = useState<string>(
|
const [internalTabId, setInternalTabId] = useState<string>(
|
||||||
defaultTabId || tabs[0]?.id || ''
|
defaultTabId || tabs[0]?.id || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeTabId = controlledTabId ?? internalTabId;
|
||||||
|
|
||||||
const handleTabClick = (tabId: string) => {
|
const handleTabClick = (tabId: string) => {
|
||||||
setActiveTabId(tabId);
|
if (!controlledTabId) setInternalTabId(tabId);
|
||||||
onTabChange?.(tabId);
|
onTabChange?.(tabId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -697,86 +698,3 @@ export function useFileOperations() {
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye, FaTimes, FaStream, FaStop } from 'react-icons/fa';
|
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye, FaTimes, FaStream, FaStop } from 'react-icons/fa';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
|
import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
|
||||||
import { Tabs } from '../components/UiComponents/Tabs';
|
import { Tabs } from '../components/UiComponents/Tabs';
|
||||||
|
|
@ -15,7 +15,7 @@ import { useToast } from '../contexts/ToastContext';
|
||||||
import { usePrompt } from '../hooks/usePrompt';
|
import { usePrompt } from '../hooks/usePrompt';
|
||||||
import { useApiRequest } from '../hooks/useApi';
|
import { useApiRequest } from '../hooks/useApi';
|
||||||
import { formatUnixTimestamp } from '../utils/time';
|
import { formatUnixTimestamp } from '../utils/time';
|
||||||
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi';
|
import { updateWorkflow, executeGraph, deleteSystemWorkflow, fetchWorkspaceRunDetail } from '../api/workflowApi';
|
||||||
import { fetchAttributes } from '../api/attributesApi';
|
import { fetchAttributes } from '../api/attributesApi';
|
||||||
import type { AttributeDefinition } from '../api/attributesApi';
|
import type { AttributeDefinition } from '../api/attributesApi';
|
||||||
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../utils/columnTypeResolver';
|
||||||
|
|
@ -424,7 +424,12 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) =>
|
||||||
// DashboardTab — Metrics + Runs table with backend pagination
|
// DashboardTab — Metrics + Runs table with backend pagination
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
const _DashboardTab: React.FC = () => {
|
interface _DashboardTabProps {
|
||||||
|
workflowFilter?: string | null;
|
||||||
|
onRunClick?: (runId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _DashboardTab: React.FC<_DashboardTabProps> = ({ workflowFilter, onRunClick }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
|
@ -491,8 +496,7 @@ const _DashboardTab: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_loadMetrics();
|
_loadMetrics();
|
||||||
_loadRuns();
|
}, [_loadMetrics]);
|
||||||
}, [_loadMetrics, _loadRuns]);
|
|
||||||
|
|
||||||
const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused');
|
const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused');
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -531,13 +535,19 @@ const _DashboardTab: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [showError, t]);
|
}, [showError, t]);
|
||||||
|
|
||||||
|
const _initialFilters = useMemo(() => {
|
||||||
|
if (!workflowFilter) return undefined;
|
||||||
|
return { workflowId: workflowFilter };
|
||||||
|
}, [workflowFilter]);
|
||||||
|
|
||||||
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
|
const _rawRunColumns: ColumnConfig[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: 'workflowLabel',
|
key: 'workflowId',
|
||||||
label: t('Workflow'),
|
label: t('Workflow'),
|
||||||
width: 200,
|
width: 200,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
filterable: true,
|
||||||
|
displayField: 'workflowLabel',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mandateId',
|
key: 'mandateId',
|
||||||
|
|
@ -643,7 +653,9 @@ const _DashboardTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8, flexShrink: 0 }}>{t('Letzte Runs')}</h3>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexShrink: 0 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, margin: 0 }}>{t('Letzte Runs')}</h3>
|
||||||
|
</div>
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable<WorkflowRun>
|
<FormGeneratorTable<WorkflowRun>
|
||||||
data={runs}
|
data={runs}
|
||||||
|
|
@ -656,7 +668,9 @@ const _DashboardTab: React.FC = () => {
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
initialSort={[{ key: 'startedAt', direction: 'desc' }]}
|
initialSort={[{ key: 'startedAt', direction: 'desc' }]}
|
||||||
|
initialFilters={_initialFilters}
|
||||||
apiEndpoint="/api/system/workflow-runs"
|
apiEndpoint="/api/system/workflow-runs"
|
||||||
|
onRowClick={(row) => onRunClick?.(row.id)}
|
||||||
customActions={[
|
customActions={[
|
||||||
{
|
{
|
||||||
id: 'tracing',
|
id: 'tracing',
|
||||||
|
|
@ -686,7 +700,11 @@ const _DashboardTab: React.FC = () => {
|
||||||
// WorkflowsTab — Central workflow management across all instances
|
// WorkflowsTab — Central workflow management across all instances
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
const _WorkflowsTab: React.FC = () => {
|
interface _WorkflowsTabProps {
|
||||||
|
onWorkflowClick?: (workflowId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _WorkflowsTab: React.FC<_WorkflowsTabProps> = ({ onWorkflowClick }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
|
@ -1051,6 +1069,7 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onDelete={(row) => _handleDelete(row.id)}
|
onDelete={(row) => _handleDelete(row.id)}
|
||||||
|
onRowClick={(row) => onWorkflowClick?.(row.id)}
|
||||||
hookData={_hookData}
|
hookData={_hookData}
|
||||||
emptyMessage={t('Keine Workflows gefunden.')}
|
emptyMessage={t('Keine Workflows gefunden.')}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1061,29 +1080,289 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Main page with Tabs
|
// Workspace Tab (run detail only — no table)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
const _FILE_REF_KEYS = new Set(['fileId', 'documentId', 'fileIds', 'documents']);
|
||||||
|
|
||||||
|
function _isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stripFileRefKeys(value: unknown): unknown {
|
||||||
|
if (_isPlainObject(value)) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value)) {
|
||||||
|
if (_FILE_REF_KEYS.has(k)) continue;
|
||||||
|
const stripped = _stripFileRefKeys(v);
|
||||||
|
if (stripped !== undefined) out[k] = stripped;
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const out = value.map((v) => _stripFileRefKeys(v)).filter((v) => v !== undefined);
|
||||||
|
return out.length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatScalar(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _DataBlock: React.FC<{ data: unknown; emptyHint?: string }> = ({ data, emptyHint }) => {
|
||||||
|
if (data === undefined || data === null) {
|
||||||
|
return emptyHint ? <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>{emptyHint}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isPlainObject(data)) {
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return emptyHint ? <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>{emptyHint}</p> : null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
{entries.map(([k, v]) => {
|
||||||
|
const isComplex = _isPlainObject(v) || Array.isArray(v);
|
||||||
|
if (isComplex) {
|
||||||
|
return (
|
||||||
|
<details key={k} style={{ fontSize: '0.8rem' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||||
|
<code style={{ fontWeight: 500 }}>{k}</code>
|
||||||
|
</summary>
|
||||||
|
<pre style={{ fontSize: '0.75rem', maxHeight: 240, overflow: 'auto', margin: '0.25rem 0 0 1rem', background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
|
||||||
|
{JSON.stringify(v, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={k} style={{ display: 'flex', gap: '0.5rem', fontSize: '0.8rem', alignItems: 'baseline' }}>
|
||||||
|
<code style={{ color: 'var(--text-secondary)', minWidth: 140, flexShrink: 0 }}>{k}</code>
|
||||||
|
<span style={{ wordBreak: 'break-word' }}>{_formatScalar(v)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre style={{ fontSize: '0.75rem', maxHeight: 240, overflow: 'auto', margin: 0, background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> }> = ({ files }) => {
|
||||||
|
if (!files.length) return null;
|
||||||
|
const baseUrl = api.defaults.baseURL || '';
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
|
||||||
|
{files.map((f) => (
|
||||||
|
<a
|
||||||
|
key={f.id}
|
||||||
|
href={`${baseUrl}/api/files/${f.id}/download`}
|
||||||
|
download
|
||||||
|
style={{ padding: '0.3rem 0.6rem', border: '1px solid var(--border-color)', borderRadius: 4, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.78rem' }}
|
||||||
|
>
|
||||||
|
<FaDownload style={{ marginRight: 4 }} />
|
||||||
|
{f.fileName || f.id}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface _WorkspaceTabProps {
|
||||||
|
runId: string | null;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [runDetail, setRunDetail] = useState<Awaited<ReturnType<typeof fetchWorkspaceRunDetail>> | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
|
||||||
|
const _loadDetail = useCallback(async (id: string) => {
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const detail = await fetchWorkspaceRunDetail(request, id);
|
||||||
|
setRunDetail(detail);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Workspace run detail failed', e);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (runId) _loadDetail(runId);
|
||||||
|
else setRunDetail(null);
|
||||||
|
}, [runId, _loadDetail]);
|
||||||
|
|
||||||
|
if (!runId) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1rem', flex: 1, color: 'var(--text-secondary)' }}>
|
||||||
|
<p>{t('Wähle einen Run im Dashboard aus, um die Details anzuzeigen.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailLoading || !runDetail) {
|
||||||
|
return <div style={{ padding: '1rem', flex: 1 }}><p>{t('Laden…')}</p></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { run, steps, workflow, unassignedFiles } = runDetail;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1rem', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<button type="button" className={styles.secondaryButton} onClick={onBack} style={{ marginBottom: '1rem' }}>
|
||||||
|
← {t('Zurück zum Dashboard')}
|
||||||
|
</button>
|
||||||
|
<h3 style={{ margin: '0.5rem 0' }}>{run.workflowLabel || run.workflowId}</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '1rem' }}>
|
||||||
|
<span><strong>{t('Status')}:</strong> {run.status}</span>
|
||||||
|
{run.startedAt && <span><strong>{t('Start')}:</strong> {_formatTs(run.startedAt)}</span>}
|
||||||
|
{run.completedAt && <span><strong>{t('Ende')}:</strong> {_formatTs(run.completedAt)}</span>}
|
||||||
|
{workflow?.targetFeatureInstanceId && <span><strong>{t('Ziel-Instanz')}:</strong> {run.targetInstanceLabel || workflow.targetFeatureInstanceId}</span>}
|
||||||
|
{(run.costTokens ?? 0) > 0 && <span><strong>Tokens:</strong> {run.costTokens}</span>}
|
||||||
|
</div>
|
||||||
|
{run.error && (
|
||||||
|
<div style={{ padding: '0.5rem', background: 'rgba(220,53,69,0.1)', borderRadius: 6, marginBottom: '1rem', color: 'var(--danger-color)' }}>
|
||||||
|
{run.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Schritte')}</h4>
|
||||||
|
{steps.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Schritte protokolliert.')}</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
|
{steps.map((step) => {
|
||||||
|
const inputData = _stripFileRefKeys(step.inputSnapshot ?? {});
|
||||||
|
const outputData = _stripFileRefKeys(step.output ?? {});
|
||||||
|
const inputFiles = step.inputFiles ?? [];
|
||||||
|
const outputFiles = step.outputFiles ?? [];
|
||||||
|
const hasInput = inputData !== undefined || inputFiles.length > 0;
|
||||||
|
const hasOutput = outputData !== undefined || outputFiles.length > 0;
|
||||||
|
return (
|
||||||
|
<details key={step.id} style={{ border: '1px solid var(--border-color)', borderRadius: 6, padding: '0.5rem' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: 500 }}>
|
||||||
|
<span style={{ marginRight: '0.5rem', fontSize: '0.75rem', padding: '2px 6px', borderRadius: 4, background: step.status === 'completed' ? 'rgba(40,167,69,0.15)' : step.status === 'failed' ? 'rgba(220,53,69,0.15)' : 'rgba(0,123,255,0.15)', color: step.status === 'completed' ? 'var(--success-color)' : step.status === 'failed' ? 'var(--danger-color)' : 'var(--primary-color)' }}>
|
||||||
|
{step.status}
|
||||||
|
</span>
|
||||||
|
{step.nodeType} ({step.nodeId})
|
||||||
|
{step.durationMs != null && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{step.durationMs}ms</span>}
|
||||||
|
{(step.tokensUsed ?? 0) > 0 && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{step.tokensUsed} tokens</span>}
|
||||||
|
</summary>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '0.5rem' }}>
|
||||||
|
{hasInput && (
|
||||||
|
<section>
|
||||||
|
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
|
||||||
|
{t('Input')}
|
||||||
|
</div>
|
||||||
|
<_DataBlock data={inputData} />
|
||||||
|
<_FileLinkList files={inputFiles} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{hasOutput && (
|
||||||
|
<section>
|
||||||
|
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
|
||||||
|
{t('Output')}
|
||||||
|
</div>
|
||||||
|
<_DataBlock data={outputData} />
|
||||||
|
<_FileLinkList files={outputFiles} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{step.error && (
|
||||||
|
<section>
|
||||||
|
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--danger-color)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
|
||||||
|
{t('Fehler')}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--danger-color)', margin: 0, fontSize: '0.85rem' }}>{step.error}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.75rem', color: 'var(--text-secondary)', borderTop: '1px solid var(--border-color)', paddingTop: '0.4rem' }}>
|
||||||
|
{step.startedAt && <span>{t('Start')}: {_formatTs(step.startedAt)}</span>}
|
||||||
|
{step.completedAt && <span>{t('Ende')}: {_formatTs(step.completedAt)}</span>}
|
||||||
|
{(step.retryCount ?? 0) > 0 && <span>{t('Wiederholungen')}: {step.retryCount}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{unassignedFiles && unassignedFiles.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
|
||||||
|
<_FileLinkList files={unassignedFiles} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Main page with Tabs (Workflows → Dashboard → Workspace)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
export const AutomationsDashboardPage: React.FC = () => {
|
export const AutomationsDashboardPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const initialTab = searchParams.get('tab') || 'workflows';
|
||||||
|
const initialRunId = searchParams.get('runId') || null;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(initialRunId ? 'workspace' : initialTab);
|
||||||
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(initialRunId);
|
||||||
|
const [workflowFilter, setWorkflowFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _handleWorkflowClick = useCallback((workflowId: string) => {
|
||||||
|
setWorkflowFilter(workflowId);
|
||||||
|
setActiveTab('dashboard');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (workflowFilter) setWorkflowFilter(null);
|
||||||
|
}, [workflowFilter]);
|
||||||
|
|
||||||
|
const _handleRunClick = useCallback((runId: string) => {
|
||||||
|
setSelectedRunId(runId);
|
||||||
|
setActiveTab('workspace');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _handleBackFromWorkspace = useCallback(() => {
|
||||||
|
setSelectedRunId(null);
|
||||||
|
setActiveTab('dashboard');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const tabs = useMemo(() => [
|
const tabs = useMemo(() => [
|
||||||
{
|
|
||||||
id: 'dashboard',
|
|
||||||
label: t('Dashboard'),
|
|
||||||
content: <_DashboardTab />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'workflows',
|
id: 'workflows',
|
||||||
label: t('Workflows'),
|
label: t('Workflows'),
|
||||||
content: <_WorkflowsTab />,
|
content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} />,
|
||||||
},
|
},
|
||||||
], [t]);
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: t('Dashboard'),
|
||||||
|
content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workspace',
|
||||||
|
label: t('Workspace'),
|
||||||
|
content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />,
|
||||||
|
},
|
||||||
|
], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<h1 className={styles.pageTitle} style={{ flexShrink: 0 }}>{t('Automatisierung')}</h1>
|
<h1 className={styles.pageTitle} style={{ flexShrink: 0 }}>{t('Automatisierung')}</h1>
|
||||||
<Tabs tabs={tabs} defaultTabId="dashboard" />
|
<Tabs tabs={tabs} activeTabId={activeTab} onTabChange={setActiveTab} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
90
src/pages/basedata/ConnectionsPage.module.css
Normal file
90
src/pages/basedata/ConnectionsPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
} catch (error) {
|
if (knowledgeEnabled) {
|
||||||
console.error('Error creating Google connection:', error);
|
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' };
|
||||||
|
showSyncBanner(LABELS[type] ?? type);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateMicrosoft = async () => {
|
|
||||||
if (isConnecting) return;
|
|
||||||
try {
|
|
||||||
await createMicrosoftConnectionAndAuth();
|
|
||||||
refetch();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating Microsoft connection:', error);
|
console.error('Error creating connection via wizard:', 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,84 +288,18 @@ 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={{
|
|
||||||
width: `${leftWidth}%`,
|
|
||||||
minWidth: 0,
|
|
||||||
overflow: 'auto',
|
|
||||||
borderRight: '1px solid var(--color-border, #e0e0e0)',
|
|
||||||
padding: '8px 4px',
|
|
||||||
}}>
|
|
||||||
<FolderTree
|
|
||||||
folders={folderNodes}
|
|
||||||
files={treeFileNodes}
|
|
||||||
showFiles={true}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Resizable divider */}
|
|
||||||
<div
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
cursor: 'col-resize',
|
|
||||||
background: isDragging ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.2))' : 'transparent',
|
|
||||||
transition: isDragging ? 'none' : 'background 0.15s',
|
|
||||||
flexShrink: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--color-border-hover, #bbb)'; }}
|
|
||||||
onMouseLeave={(e) => { if (!isDragging) (e.target as HTMLElement).style.background = 'transparent'; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Right panel: File table */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', gap: 8, padding: '8px 12px',
|
display: 'flex', gap: 8, padding: '8px 12px',
|
||||||
borderBottom: '1px solid var(--color-border, #e0e0e0)',
|
borderBottom: '1px solid var(--color-border, #e0e0e0)',
|
||||||
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
|
flexShrink: 0, alignItems: 'center', flexWrap: 'wrap',
|
||||||
}}>
|
}}>
|
||||||
<button className={styles.secondaryButton} onClick={_handleNewFolder}>
|
|
||||||
<FaFolderPlus /> {t('Neuer Ordner')}
|
|
||||||
</button>
|
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
|
||||||
<FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')}
|
<FaUpload /> {uploadingFile ? t('Wird hochgeladen…') : t('Datei hochladen')}
|
||||||
|
|
@ -511,13 +322,10 @@ export const FilesPage: React.FC = () => {
|
||||||
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
onRowSelect={(rows) => setSelectedFiles(rows as UserFile[])}
|
||||||
rowDraggable={true}
|
rowDraggable={true}
|
||||||
onRowDragStart={_onRowDragStart}
|
onRowDragStart={_onRowDragStart}
|
||||||
getRowDataAttributes={(row: UserFile) =>
|
|
||||||
({ highlighted: row.id === highlightedFileId ? 'true' : 'false' })
|
|
||||||
}
|
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
{
|
{
|
||||||
type: 'view' as const,
|
type: 'view' as const,
|
||||||
onAction: () => { /* ContentPreview fetches the file itself once the popup opens */ },
|
onAction: () => {},
|
||||||
title: t('Vorschau'),
|
title: t('Vorschau'),
|
||||||
idField: 'id',
|
idField: 'id',
|
||||||
nameField: 'fileName',
|
nameField: 'fileName',
|
||||||
|
|
@ -556,12 +364,14 @@ export const FilesPage: React.FC = () => {
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically: updateFileOptimistically,
|
updateOptimistically: updateFileOptimistically,
|
||||||
previewingFiles,
|
previewingFiles,
|
||||||
|
groupTree,
|
||||||
}}
|
}}
|
||||||
emptyMessage={emptyTableMessage}
|
groupingConfig={{ contextKey: 'files/list', enabled: true }}
|
||||||
|
groupBulkActionsProvider={_groupBulkActionsProvider}
|
||||||
|
emptyMessage={t('Keine Dateien gefunden')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{editingFile && (
|
{editingFile && (
|
||||||
<div className={styles.modalOverlay}>
|
<div className={styles.modalOverlay}>
|
||||||
|
|
@ -591,7 +401,6 @@ export const FilesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PromptDialog />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
} from '../../../api/workflowApi';
|
} from '../../../api/workflowApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { Popup } from '../../../components/UiComponents/Popup';
|
import { Popup } from '../../../components/UiComponents/Popup';
|
||||||
import { getAcceptStringFromConfig } from '../../../components/FlowEditor';
|
import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor';
|
||||||
import { useFileOperations } from '../../../hooks/useFiles';
|
import { useFileOperations } from '../../../hooks/useFiles';
|
||||||
import styles from './Automation2WorkflowsTasks.module.css';
|
import styles from './Automation2WorkflowsTasks.module.css';
|
||||||
|
|
||||||
|
|
@ -501,25 +501,6 @@ function InputFormClickupTaskField({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileMatchesAccept(file: File, accept: string): boolean {
|
|
||||||
if (!accept || !accept.trim()) return true;
|
|
||||||
const parts = accept.split(',').map((s) => s.trim()).filter(Boolean);
|
|
||||||
const ext = '.' + (file.name.split('.').pop() ?? '').toLowerCase();
|
|
||||||
const mime = (file.type ?? '').toLowerCase();
|
|
||||||
for (const p of parts) {
|
|
||||||
const pp = p.toLowerCase();
|
|
||||||
if (pp.startsWith('.')) {
|
|
||||||
if (ext === pp) return true;
|
|
||||||
const exts = pp.split(',').map((x) => (x.trim().startsWith('.') ? x.trim() : '.' + x.trim()));
|
|
||||||
if (exts.some((e) => e === ext)) return true;
|
|
||||||
} else if (pp.endsWith('/*')) {
|
|
||||||
const prefix = pp.slice(0, -2);
|
|
||||||
if (mime.startsWith(prefix)) return true;
|
|
||||||
} else if (mime === pp || mime.startsWith(pp + '/')) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskCard: React.FC<TaskCardProps> = ({
|
const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
task,
|
task,
|
||||||
instanceId,
|
instanceId,
|
||||||
|
|
@ -787,8 +768,10 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (acceptStr && !fileMatchesAccept(file, acceptStr)) {
|
if (acceptStr && acceptStr !== '*' && !fileMatchesAccept(file, acceptStr)) {
|
||||||
setUploadError(`Dateityp von "${file.name}" nicht erlaubt.`);
|
setUploadError(
|
||||||
|
`Die Datei „${file.name}“ hat ein nicht erlaubtes Format. Bitte eine Datei mit passender Endung verwenden (laut Upload-Schritt im Workflow).`,
|
||||||
|
);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
return;
|
return;
|
||||||
|
|
@ -848,7 +831,7 @@ const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={acceptStr || undefined}
|
accept={acceptStr === '*' ? undefined : acceptStr || undefined}
|
||||||
multiple={allowMultiple}
|
multiple={allowMultiple}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
|
@ -76,6 +76,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
|
||||||
|
|
||||||
export const TrusteeAbschlussView: React.FC = () => {
|
export const TrusteeAbschlussView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { instanceId } = useCurrentInstance();
|
const { instanceId } = useCurrentInstance();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -325,6 +326,25 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
{runState === 'error' && t('Fehler')}
|
{runState === 'error' && t('Fehler')}
|
||||||
{runSummary && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
|
{runSummary && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
|
||||||
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
|
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
|
||||||
|
{runState === 'completed' && runId && (
|
||||||
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
|
<a
|
||||||
|
href={`/automations?tab=workspace&runId=${runId}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/automations?tab=workspace&runId=${runId}`);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
||||||
|
background: 'var(--primary-color, #007bff)', color: '#fff',
|
||||||
|
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Im Workspace ansehen')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
|
@ -102,6 +102,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
|
||||||
|
|
||||||
export const TrusteeAnalyseView: React.FC = () => {
|
export const TrusteeAnalyseView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { instanceId } = useCurrentInstance();
|
const { instanceId } = useCurrentInstance();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -121,9 +122,6 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
const pollTimerRef = useRef<number | null>(null);
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
const isPollingRef = useRef(false);
|
const isPollingRef = useRef(false);
|
||||||
|
|
||||||
const [resultText, setResultText] = useState<string | null>(null);
|
|
||||||
const [resultDocuments, setResultDocuments] = useState<Array<{ id?: string; fileName?: string; mimeType?: string }>>([]);
|
|
||||||
|
|
||||||
const [budgetFileId, setBudgetFileId] = useState<string | null>(null);
|
const [budgetFileId, setBudgetFileId] = useState<string | null>(null);
|
||||||
const [budgetFileName, setBudgetFileName] = useState<string | null>(null);
|
const [budgetFileName, setBudgetFileName] = useState<string | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
@ -202,12 +200,6 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
|
if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
|
||||||
setRunState('completed');
|
setRunState('completed');
|
||||||
_stopPolling();
|
_stopPolling();
|
||||||
const lastStep = [...steps].reverse().find((s) => s.status === 'completed' && s.output);
|
|
||||||
if (lastStep?.output) {
|
|
||||||
setResultText(lastStep.output.response || lastStep.output.context || null);
|
|
||||||
const docs = lastStep.output.documents || lastStep.output.documentList || [];
|
|
||||||
setResultDocuments(Array.isArray(docs) ? docs : []);
|
|
||||||
}
|
|
||||||
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -234,25 +226,6 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
|
useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
|
||||||
|
|
||||||
const _extractResults = useCallback((nodeOutputs: Record<string, any>) => {
|
|
||||||
const analyseOut = nodeOutputs?.analyse || nodeOutputs?.result;
|
|
||||||
if (!analyseOut) {
|
|
||||||
for (const key of Object.keys(nodeOutputs || {})) {
|
|
||||||
const v = nodeOutputs[key];
|
|
||||||
if (v && typeof v === 'object' && (v.response || v.documents)) {
|
|
||||||
setResultText(v.response || v.context || null);
|
|
||||||
const docs = v.documents || v.documentList || [];
|
|
||||||
setResultDocuments(Array.isArray(docs) ? docs : []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setResultText(analyseOut.response || analyseOut.context || null);
|
|
||||||
const docs = analyseOut.documents || analyseOut.documentList || [];
|
|
||||||
setResultDocuments(Array.isArray(docs) ? docs : []);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reset run state when tab changes
|
// Reset run state when tab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_stopPolling();
|
_stopPolling();
|
||||||
|
|
@ -260,8 +233,6 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
setRunId(null);
|
setRunId(null);
|
||||||
setRunSummary('');
|
setRunSummary('');
|
||||||
setRunError(null);
|
setRunError(null);
|
||||||
setResultText(null);
|
|
||||||
setResultDocuments([]);
|
|
||||||
}, [activeTab, _stopPolling]);
|
}, [activeTab, _stopPolling]);
|
||||||
|
|
||||||
const _handleBudgetUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const _handleBudgetUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -304,8 +275,6 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
setRunState('starting');
|
setRunState('starting');
|
||||||
setRunError(null);
|
setRunError(null);
|
||||||
setRunSummary(t('Workflow wird gestartet…'));
|
setRunSummary(t('Workflow wird gestartet…'));
|
||||||
setResultText(null);
|
|
||||||
setResultDocuments([]);
|
|
||||||
try {
|
try {
|
||||||
const executeBody: Record<string, any> = { workflowId: wf.id };
|
const executeBody: Record<string, any> = { workflowId: wf.id };
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
|
|
@ -325,9 +294,6 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
} else if (res?.data?.success) {
|
} else if (res?.data?.success) {
|
||||||
setRunState('completed');
|
setRunState('completed');
|
||||||
setRunSummary(t('Workflow synchron abgeschlossen.'));
|
setRunSummary(t('Workflow synchron abgeschlossen.'));
|
||||||
if (res.data.nodeOutputs) {
|
|
||||||
_extractResults(res.data.nodeOutputs);
|
|
||||||
}
|
|
||||||
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
showSuccess(t('Abgeschlossen'), t('Analyse-Workflow erfolgreich beendet.'));
|
||||||
} else {
|
} else {
|
||||||
throw new Error(res?.data?.error || t('Unerwartete Antwort'));
|
throw new Error(res?.data?.error || t('Unerwartete Antwort'));
|
||||||
|
|
@ -407,6 +373,9 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
<div style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem' }}>
|
<div style={{ fontWeight: 600, marginBottom: '0.5rem', fontSize: '0.875rem' }}>
|
||||||
{t('Budget-Excel hochladen')}
|
{t('Budget-Excel hochladen')}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginBottom: '0.5rem' }}>
|
||||||
|
{t('Ergebnis: Excel-Bericht mit Konten-Tabelle, Uebersichts-Chart und Management-Summary.')}
|
||||||
|
</div>
|
||||||
{budgetFileName ? (
|
{budgetFileName ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span style={{ fontSize: '0.875rem' }}>📄 {budgetFileName}</span>
|
<span style={{ fontSize: '0.875rem' }}>📄 {budgetFileName}</span>
|
||||||
|
|
@ -481,32 +450,25 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Workspace link (replaces inline results) */}
|
||||||
{runState === 'completed' && (resultText || resultDocuments.length > 0) && (
|
{runState === 'completed' && runId && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: '0.5rem',
|
marginTop: '0.5rem',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
border: '1px solid var(--border-color, #e0e0e0)',
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
overflow: 'hidden',
|
background: 'rgba(40,167,69,0.08)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.75rem',
|
||||||
}}>
|
}}>
|
||||||
{resultDocuments.length > 0 && (
|
<span style={{ fontSize: '0.875rem', color: 'var(--success-color, #28a745)' }}>
|
||||||
<div style={{
|
{t('Workflow abgeschlossen.')}
|
||||||
padding: '0.75rem 1rem',
|
</span>
|
||||||
background: 'var(--bg-secondary, #f9f9f9)',
|
|
||||||
borderBottom: resultText ? '1px solid var(--border-color, #e0e0e0)' : 'none',
|
|
||||||
display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center',
|
|
||||||
}}>
|
|
||||||
<strong style={{ fontSize: '0.8125rem' }}>{t('Generierte Dokumente:')}</strong>
|
|
||||||
{resultDocuments.map((doc, idx) => {
|
|
||||||
const docId = doc.id || (typeof doc === 'string' ? doc : null);
|
|
||||||
const docName = doc.fileName || `Dokument ${idx + 1}`;
|
|
||||||
if (!docId) return null;
|
|
||||||
return (
|
|
||||||
<a
|
<a
|
||||||
key={docId}
|
href={`/automations?tab=workspace&runId=${runId}`}
|
||||||
href={`${api.defaults.baseURL || ''}/api/files/${docId}/download`}
|
onClick={(e) => {
|
||||||
target="_blank"
|
e.preventDefault();
|
||||||
rel="noopener noreferrer"
|
navigate(`/automations?tab=workspace&runId=${runId}`);
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||||
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
padding: '0.375rem 0.75rem', borderRadius: '6px',
|
||||||
|
|
@ -514,25 +476,8 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
📄 {docName}
|
{t('Im Workspace ansehen')}
|
||||||
</a>
|
</a>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{resultText && (
|
|
||||||
<div style={{
|
|
||||||
padding: '1rem',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
maxHeight: '400px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
background: 'var(--bg-primary, #fff)',
|
|
||||||
}}>
|
|
||||||
{resultText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@ interface MaxAgentRoundsInfo {
|
||||||
instanceDefault: number;
|
instanceDefault: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AiUserSettings {
|
||||||
|
requireNeutralization: boolean;
|
||||||
|
allowedProviders: string[];
|
||||||
|
allowedModels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AiModelEntry {
|
||||||
|
displayName: string;
|
||||||
|
connectorType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ instanceId }) => {
|
export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ instanceId }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
|
@ -36,6 +47,16 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
});
|
});
|
||||||
const [inputValue, setInputValue] = useState<string>('');
|
const [inputValue, setInputValue] = useState<string>('');
|
||||||
|
|
||||||
|
// AI user settings
|
||||||
|
const [aiSettings, setAiSettings] = useState<AiUserSettings>({
|
||||||
|
requireNeutralization: false,
|
||||||
|
allowedProviders: [],
|
||||||
|
allowedModels: [],
|
||||||
|
});
|
||||||
|
const [aiSaving, setAiSaving] = useState(false);
|
||||||
|
const [availableModels, setAvailableModels] = useState<AiModelEntry[]>([]);
|
||||||
|
const [modelsOpen, setModelsOpen] = useState(false);
|
||||||
|
|
||||||
const _loadSettings = useCallback(async () => {
|
const _loadSettings = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -56,9 +77,37 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
}
|
}
|
||||||
}, [instanceId, request]);
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const _loadAiSettings = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
try {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/workspace/${instanceId}/user-settings`,
|
||||||
|
method: 'get',
|
||||||
|
}) as AiUserSettings;
|
||||||
|
setAiSettings({
|
||||||
|
requireNeutralization: data?.requireNeutralization ?? false,
|
||||||
|
allowedProviders: data?.allowedProviders ?? [],
|
||||||
|
allowedModels: data?.allowedModels ?? [],
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[WorkspaceGeneralSettings] AI settings load failed', err);
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const _loadAvailableModels = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await request({ url: '/api/system/ai-models', method: 'get' }) as { models?: AiModelEntry[] };
|
||||||
|
setAvailableModels(data?.models ?? []);
|
||||||
|
} catch {
|
||||||
|
setAvailableModels([]);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
_loadSettings();
|
_loadSettings();
|
||||||
}, [_loadSettings]);
|
_loadAiSettings();
|
||||||
|
_loadAvailableModels();
|
||||||
|
}, [_loadSettings, _loadAiSettings, _loadAvailableModels]);
|
||||||
|
|
||||||
const _handleSave = async () => {
|
const _handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -94,11 +143,49 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _saveAiSettings = async (patch: Partial<AiUserSettings>) => {
|
||||||
|
setAiSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/workspace/${instanceId}/user-settings`,
|
||||||
|
method: 'put',
|
||||||
|
data: patch,
|
||||||
|
}) as AiUserSettings;
|
||||||
|
setAiSettings({
|
||||||
|
requireNeutralization: data?.requireNeutralization ?? false,
|
||||||
|
allowedProviders: data?.allowedProviders ?? [],
|
||||||
|
allowedModels: data?.allowedModels ?? [],
|
||||||
|
});
|
||||||
|
setSuccess(t('KI-Einstellungen gespeichert'));
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Fehler beim Speichern der KI-Einstellungen');
|
||||||
|
} finally {
|
||||||
|
setAiSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _toggleModel = (name: string) => {
|
||||||
|
const next = aiSettings.allowedModels.includes(name)
|
||||||
|
? aiSettings.allowedModels.filter((m) => m !== name)
|
||||||
|
: [...aiSettings.allowedModels, name];
|
||||||
|
setAiSettings((prev) => ({ ...prev, allowedModels: next }));
|
||||||
|
_saveAiSettings({ allowedModels: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const _removeModelTag = (name: string) => {
|
||||||
|
const next = aiSettings.allowedModels.filter((m) => m !== name);
|
||||||
|
setAiSettings((prev) => ({ ...prev, allowedModels: next }));
|
||||||
|
_saveAiSettings({ allowedModels: next });
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.loading}>{t('Lade Einstellungen')}</div>;
|
return <div className={styles.loading}>{t('Lade Einstellungen')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasOverride = inputValue.trim() !== '';
|
const hasOverride = inputValue.trim() !== '';
|
||||||
|
const providerNames = [...new Set(availableModels.map((m) => m.connectorType).filter(Boolean))] as string[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<div className={styles.settings}>
|
||||||
|
|
@ -151,6 +238,133 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
>
|
>
|
||||||
{saving ? t('Speichern') : t('Einstellungen speichern')}
|
{saving ? t('Speichern') : t('Einstellungen speichern')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* AI settings section */}
|
||||||
|
<div className={styles.section} style={{ marginTop: '1.5rem' }}>
|
||||||
|
<h3 className={styles.sectionTitle}>{t('KI-Einstellungen')}</h3>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiSettings.requireNeutralization}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.checked;
|
||||||
|
setAiSettings((prev) => ({ ...prev, requireNeutralization: val }));
|
||||||
|
_saveAiSettings({ requireNeutralization: val });
|
||||||
|
}}
|
||||||
|
disabled={aiSaving}
|
||||||
|
/>
|
||||||
|
<span className={styles.label} style={{ marginBottom: 0 }}>
|
||||||
|
{t('Neutralisierung erzwingen')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
|
||||||
|
{t('Erzwingt die Neutralisierung von Eingaben vor der KI-Verarbeitung.')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{providerNames.length > 0 && (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>{t('Erlaubte Anbieter')}</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{providerNames.map((prov) => (
|
||||||
|
<label key={prov} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.85rem', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiSettings.allowedProviders.includes(prov)}
|
||||||
|
onChange={() => {
|
||||||
|
const next = aiSettings.allowedProviders.includes(prov)
|
||||||
|
? aiSettings.allowedProviders.filter((p) => p !== prov)
|
||||||
|
: [...aiSettings.allowedProviders, prov];
|
||||||
|
setAiSettings((prev) => ({ ...prev, allowedProviders: next }));
|
||||||
|
_saveAiSettings({ allowedProviders: next });
|
||||||
|
}}
|
||||||
|
disabled={aiSaving}
|
||||||
|
/>
|
||||||
|
{prov}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{aiSettings.allowedProviders.length === 0 && (
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
|
||||||
|
{t('Alle Anbieter erlaubt')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>{t('Erlaubte Modelle')}</label>
|
||||||
|
<div
|
||||||
|
onClick={() => setModelsOpen((o) => !o)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 36,
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid var(--border-color, #d0d0d0)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 4,
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{aiSettings.allowedModels.length === 0 && (
|
||||||
|
<span style={{ color: 'var(--text-secondary, #999)', fontSize: '0.85rem' }}>{t('Alle erlaubten Modelle')}</span>
|
||||||
|
)}
|
||||||
|
{aiSettings.allowedModels.map((name) => (
|
||||||
|
<span
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
background: 'var(--primary-color, #2563eb)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '2px 8px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
<span
|
||||||
|
onClick={(e) => { e.stopPropagation(); _removeModelTag(name); }}
|
||||||
|
style={{ cursor: 'pointer', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{modelsOpen && (
|
||||||
|
<div style={{ border: '1px solid var(--border-color, #ddd)', borderRadius: 6, marginTop: 4, maxHeight: 220, overflow: 'auto', background: 'var(--bg-primary, #fafafa)', padding: 6 }}>
|
||||||
|
{availableModels.length === 0 && (
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
|
||||||
|
)}
|
||||||
|
{availableModels.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.displayName}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 6px', fontSize: '0.85rem', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiSettings.allowedModels.includes(m.displayName)}
|
||||||
|
onChange={() => _toggleModel(m.displayName)}
|
||||||
|
disabled={aiSaving}
|
||||||
|
/>
|
||||||
|
<span>{m.displayName}</span>
|
||||||
|
{m.connectorType && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)' }}>({m.connectorType})</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue